@otto-assistant/otto 0.1.1 → 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 (637) 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/dist/cli.d.ts +0 -3
  533. package/dist/cli.d.ts.map +0 -1
  534. package/dist/cli.js.map +0 -1
  535. package/dist/config.d.ts +0 -39
  536. package/dist/config.d.ts.map +0 -1
  537. package/dist/config.js.map +0 -1
  538. package/dist/config.test.d.ts +0 -2
  539. package/dist/config.test.d.ts.map +0 -1
  540. package/dist/config.test.js +0 -202
  541. package/dist/config.test.js.map +0 -1
  542. package/dist/detect.d.ts +0 -9
  543. package/dist/detect.d.ts.map +0 -1
  544. package/dist/detect.js +0 -40
  545. package/dist/detect.js.map +0 -1
  546. package/dist/detect.test.d.ts +0 -2
  547. package/dist/detect.test.d.ts.map +0 -1
  548. package/dist/detect.test.js +0 -26
  549. package/dist/detect.test.js.map +0 -1
  550. package/dist/docker.d.ts +0 -7
  551. package/dist/docker.d.ts.map +0 -1
  552. package/dist/docker.js +0 -17
  553. package/dist/docker.js.map +0 -1
  554. package/dist/docker.test.d.ts +0 -2
  555. package/dist/docker.test.d.ts.map +0 -1
  556. package/dist/docker.test.js +0 -12
  557. package/dist/docker.test.js.map +0 -1
  558. package/dist/health.d.ts +0 -31
  559. package/dist/health.d.ts.map +0 -1
  560. package/dist/health.js +0 -117
  561. package/dist/health.js.map +0 -1
  562. package/dist/health.test.d.ts +0 -2
  563. package/dist/health.test.d.ts.map +0 -1
  564. package/dist/health.test.js +0 -52
  565. package/dist/health.test.js.map +0 -1
  566. package/dist/index.d.ts +0 -20
  567. package/dist/index.d.ts.map +0 -1
  568. package/dist/index.js +0 -15
  569. package/dist/index.js.map +0 -1
  570. package/dist/index.test.d.ts +0 -2
  571. package/dist/index.test.d.ts.map +0 -1
  572. package/dist/index.test.js +0 -8
  573. package/dist/index.test.js.map +0 -1
  574. package/dist/installer.d.ts +0 -10
  575. package/dist/installer.d.ts.map +0 -1
  576. package/dist/installer.js +0 -50
  577. package/dist/installer.js.map +0 -1
  578. package/dist/installer.test.d.ts +0 -2
  579. package/dist/installer.test.d.ts.map +0 -1
  580. package/dist/installer.test.js +0 -43
  581. package/dist/installer.test.js.map +0 -1
  582. package/dist/lifecycle.d.ts +0 -10
  583. package/dist/lifecycle.d.ts.map +0 -1
  584. package/dist/lifecycle.js +0 -45
  585. package/dist/lifecycle.js.map +0 -1
  586. package/dist/lifecycle.test.d.ts +0 -2
  587. package/dist/lifecycle.test.d.ts.map +0 -1
  588. package/dist/lifecycle.test.js +0 -20
  589. package/dist/lifecycle.test.js.map +0 -1
  590. package/dist/manifest.d.ts +0 -18
  591. package/dist/manifest.d.ts.map +0 -1
  592. package/dist/manifest.js +0 -30
  593. package/dist/manifest.js.map +0 -1
  594. package/dist/skills-baseline.d.ts +0 -7
  595. package/dist/skills-baseline.d.ts.map +0 -1
  596. package/dist/skills-baseline.js +0 -9
  597. package/dist/skills-baseline.js.map +0 -1
  598. package/dist/skills.d.ts +0 -110
  599. package/dist/skills.d.ts.map +0 -1
  600. package/dist/skills.js +0 -429
  601. package/dist/skills.js.map +0 -1
  602. package/dist/skills.test.d.ts +0 -2
  603. package/dist/skills.test.d.ts.map +0 -1
  604. package/dist/skills.test.js +0 -416
  605. package/dist/skills.test.js.map +0 -1
  606. package/dist/sync.d.ts +0 -10
  607. package/dist/sync.d.ts.map +0 -1
  608. package/dist/sync.js +0 -39
  609. package/dist/sync.js.map +0 -1
  610. package/dist/tenant.d.ts +0 -13
  611. package/dist/tenant.d.ts.map +0 -1
  612. package/dist/tenant.js +0 -105
  613. package/dist/tenant.js.map +0 -1
  614. package/dist/tenant.test.d.ts +0 -2
  615. package/dist/tenant.test.d.ts.map +0 -1
  616. package/dist/tenant.test.js +0 -37
  617. package/dist/tenant.test.js.map +0 -1
  618. package/src/config.test.ts +0 -237
  619. package/src/detect.test.ts +0 -29
  620. package/src/detect.ts +0 -52
  621. package/src/docker.test.ts +0 -12
  622. package/src/docker.ts +0 -23
  623. package/src/health.test.ts +0 -61
  624. package/src/health.ts +0 -158
  625. package/src/index.test.ts +0 -8
  626. package/src/index.ts +0 -62
  627. package/src/installer.test.ts +0 -52
  628. package/src/installer.ts +0 -62
  629. package/src/lifecycle.test.ts +0 -23
  630. package/src/lifecycle.ts +0 -49
  631. package/src/manifest.ts +0 -42
  632. package/src/skills-baseline.ts +0 -14
  633. package/src/skills.test.ts +0 -503
  634. package/src/skills.ts +0 -512
  635. package/src/sync.ts +0 -53
  636. package/src/tenant.test.ts +0 -49
  637. package/src/tenant.ts +0 -120
package/dist/cli.js CHANGED
@@ -1,651 +1,4293 @@
1
1
  #!/usr/bin/env node
2
- import { MANIFEST } from "./manifest.js";
3
- import { getInstalledVersion } from "./detect.js";
4
- import { readOpenCodeConfigState, writeOpenCodeConfig, ensureAgentMemoryConfig, ensureSubagentThreadSkill, mergePlugins, readOttoConfig, writeOttoConfig, buildSubagentThreadPolicy, mergeAgentPrompts, } from "./config.js";
5
- import { installMissingPackages, upgradePackage, planStableUpgrades } from "./installer.js";
6
- import fs from "node:fs";
7
- import path from "node:path";
8
- import { hasKimakiBinary, restartKimaki } from "./lifecycle.js";
9
- import { checkPackagePresence, checkConfigHealth, checkDirectoryHealth, checkTenantHealth } from "./health.js";
10
- import { syncUpstreams } from "./sync.js";
11
- import { runCompose } from "./docker.js";
12
- import { ensureTenantScaffold, resolveTenantMode } from "./tenant.js";
13
- import { searchSkills, getAllIndexedSkills, listInstalledSkills, installSkillFromIndex, installSkillsBaseline, removeSkill, ensureSkillsIndex, getConfiguredRepos, loadSkillsIndex, } from "./skills.js";
14
- import { GENTLEMAN_SKILLS_BASELINE } from "./skills-baseline.js";
15
- const args = process.argv.slice(2);
16
- const command = args[0] ?? "";
17
- const subCommand = args[1] ?? "";
18
- function mergeOttoManagedConfig(config) {
19
- let merged = config;
20
- for (const plugin of MANIFEST.plugins) {
21
- merged = mergePlugins(merged, plugin);
22
- }
23
- // Read Otto's own config from otto.json (NOT from opencode.json)
24
- const ottoConfig = readOttoConfig();
25
- merged = mergeAgentPrompts(merged, buildSubagentThreadPolicy(ottoConfig));
26
- return merged;
27
- }
28
- async function cmdInstall() {
29
- console.log("Otto install conservative mode\n");
30
- // 1. Install missing global npm packages (CLI tools only)
31
- const installed = installMissingPackages(getInstalledVersion);
32
- if (installed.length > 0) {
33
- console.log(`Installed: ${installed.join(", ")}`);
2
+ // Main CLI entrypoint for the Otto Discord bot.
3
+ // Handles interactive setup, Discord OAuth, slash command registration,
4
+ // project channel creation, and launching the bot with opencode integration.
5
+ import { goke } from 'goke';
6
+ import { z } from 'zod';
7
+ import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, select, spinner, } from '@clack/prompts';
8
+ import { deduplicateByKey, generateBotInstallUrl, generateDiscordInstallUrlForBot, OTTO_GATEWAY_APP_ID, OTTO_WEBSITE_URL, abbreviatePath, buildOpenUrlCommand, getGatewayInstallTargetName, getGatewayPlatformDisplayName, shouldRegisterSlashCommands, } from './utils.js';
9
+ import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureOttoCategory, createProjectChannels, createDefaultOttoChannel, } from './discord-bot.js';
10
+ import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getPrisma, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, getThreadDeletionSyncMode, setThreadDeletionSyncMode, resetThreadDeletionSyncMode, } from './database.js';
11
+ import { ShareMarkdown } from './markdown.js';
12
+ import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts, } from './session-search.js';
13
+ import { formatWorktreeName, formatAutoWorktreeName } from './commands/new-worktree.js';
14
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
15
+ import { sendWelcomeMessage } from './onboarding-welcome.js';
16
+ import { buildOpencodeEventLogLine } from './session-handler/opencode-session-event-log.js';
17
+ import { selectResolvedCommand } from './opencode-command.js';
18
+ import YAML from 'yaml';
19
+ import { Events, ChannelType, ActivityType, Routes, AttachmentBuilder, } from 'discord.js';
20
+ import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInternetReachableBaseUrl } from './discord-urls.js';
21
+ import crypto from 'node:crypto';
22
+ import path from 'node:path';
23
+ import fs from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
25
+ import * as errore from 'errore';
26
+ import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './logger.js';
27
+ import { initSentry, notifyError } from './sentry.js';
28
+ import { archiveThread, uploadFilesToDiscord, stripMentions, } from './discord-utils.js';
29
+ import { spawn, execSync, } from 'node:child_process';
30
+ import { setDataDir, setProjectsDir, getDataDir, getProjectsDir, } from './config.js';
31
+ import { execAsync, validateWorktreeDirectory } from './worktrees.js';
32
+ import { backgroundUpgradeOtto, upgrade, getCurrentVersion, } from './upgrade.js';
33
+ import { startHranaServer } from './hrana-server.js';
34
+ import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
35
+ import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, } from './task-schedule.js';
36
+ function isTelegramBridgeModule(value) {
37
+ if (!value || typeof value !== 'object') {
38
+ return false;
39
+ }
40
+ const withBridge = value;
41
+ return typeof withBridge.TelegramBridge === 'function';
42
+ }
43
+ import { accountLabel, accountsFilePath, authFilePath, getCurrentAnthropicAccount, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
44
+ const cliLogger = createLogger(LogPrefix.CLI);
45
+ // Gateway bot mode constants.
46
+ // OTTO_GATEWAY_APP_ID is the Discord Application ID of the gateway bot.
47
+ // OTTO_WEBSITE_URL is the website that handles OAuth callback + onboarding status.
48
+ // OTTO_GATEWAY_PROXY_URL is the default Discord gateway-proxy base URL.
49
+ // Telegram uses a separate OTTO_TELEGRAM_GATEWAY_PROXY_URL to avoid silently
50
+ // connecting to a non-existent hardcoded host.
51
+ const OTTO_GATEWAY_PROXY_URLS = {
52
+ discord: process.env.OTTO_GATEWAY_PROXY_URL ||
53
+ 'wss://discord-gateway.otto.dev',
54
+ telegram: process.env.OTTO_TELEGRAM_GATEWAY_PROXY_URL,
55
+ };
56
+ function getGatewayProxyRestBaseUrlForPlatform({ gatewayPlatform, }) {
57
+ const gatewayUrl = OTTO_GATEWAY_PROXY_URLS[gatewayPlatform];
58
+ if (!gatewayUrl) {
59
+ return new Error(`Missing gateway URL for platform "${gatewayPlatform}". Set OTTO_${gatewayPlatform.toUpperCase()}_GATEWAY_PROXY_URL.`);
60
+ }
61
+ return getGatewayProxyRestBaseUrl({ gatewayUrl });
62
+ }
63
+ // Strip bracketed paste escape sequences from terminal input.
64
+ // iTerm2 and other terminals wrap pasted content with \x1b[200~ and \x1b[201~
65
+ // which can cause validation to fail on macOS. See: https://github.com/remorses/otto/issues/18
66
+ function stripBracketedPaste(value) {
67
+ if (!value) {
68
+ return '';
69
+ }
70
+ return value
71
+ .replace(/\x1b\[200~/g, '')
72
+ .replace(/\x1b\[201~/g, '')
73
+ .trim();
74
+ }
75
+ function parseGatewayPlatform(value) {
76
+ if (value === 'telegram') {
77
+ return 'telegram';
78
+ }
79
+ return 'discord';
80
+ }
81
+ function getGatewayAppId({ gatewayPlatform, }) {
82
+ if (gatewayPlatform === 'telegram') {
83
+ return process.env.OTTO_TELEGRAM_GATEWAY_APP_ID || OTTO_GATEWAY_APP_ID;
84
+ }
85
+ return OTTO_GATEWAY_APP_ID;
86
+ }
87
+ // Derive the Discord Application ID from a bot token.
88
+ // Discord bot tokens have the format: base64(userId).timestamp.hmac
89
+ // The first segment is the bot's user ID (= Application ID) base64-encoded.
90
+ // For gateway mode tokens (client_id:secret format), this function returns
91
+ // undefined -- the caller should use OTTO_GATEWAY_APP_ID instead.
92
+ function appIdFromToken(token) {
93
+ // Gateway mode tokens use "client_id:secret" format, not base64.
94
+ if (token.includes(':')) {
95
+ return undefined;
96
+ }
97
+ const segment = token.split('.')[0];
98
+ if (!segment) {
99
+ return undefined;
34
100
  }
35
- else {
36
- console.log("All packages already installed.");
37
- }
38
- // 2. Merge plugins + Otto policy into opencode.json
39
- let configChanged = false;
40
- const { config, status } = readOpenCodeConfigState();
41
- if (status === "invalid") {
42
- console.error("Error: opencode.json exists but is not valid JSON. Fix the file, then run otto again.");
43
- process.exit(1);
44
- }
45
- // 2a. Ensure otto.json exists with defaults
46
- const ottoConfig = readOttoConfig();
47
- const ottoConfigChanged = writeOttoConfig(ottoConfig);
48
- // 2b. Merge plugins + policy into opencode.json (NO otto key!)
49
- const merged = mergeOttoManagedConfig(config);
50
- configChanged = writeOpenCodeConfig(merged) || configChanged;
51
- if (configChanged) {
52
- console.log(`Updated opencode.json — added plugins: ${MANIFEST.plugins.join(", ")} + otto subagent policy`);
53
- }
54
- if (ottoConfigChanged) {
55
- console.log("Created otto.json with defaults");
56
- }
57
- // 3. Ensure agent-memory.json exists
58
- const created = ensureAgentMemoryConfig();
59
- if (created) {
60
- console.log("Created agent-memory.json with defaults");
61
- }
62
- // 4. Ensure otto-subagent-threads skill exists
63
- const skillCreated = ensureSubagentThreadSkill();
64
- if (skillCreated) {
65
- console.log("Created otto-subagent-threads skill");
66
- }
67
- // 5. Install Otto-core skills from skills repo (best-effort)
68
101
  try {
69
- ensureSkillsIndex();
70
- const allSkills = getAllIndexedSkills();
71
- const installedSkillNames = new Set(listInstalledSkills().map((s) => s.name));
72
- const coreSkills = allSkills.filter((s) => s.source === "otto-assistant/skills" && !installedSkillNames.has(s.name));
73
- for (const skill of coreSkills) {
74
- const ok = installSkillFromIndex(skill.name);
75
- if (ok)
76
- console.log(`Installed skill: ${skill.name}`);
102
+ const decoded = Buffer.from(segment, 'base64').toString('utf8');
103
+ if (/^\d{17,20}$/.test(decoded)) {
104
+ return decoded;
77
105
  }
106
+ return undefined;
78
107
  }
79
108
  catch {
80
- console.log("⚠ Could not fetch skills from GitHub (offline?). Skipping.");
109
+ return undefined;
110
+ }
111
+ }
112
+ // Resolve bot token and app ID from env var or database.
113
+ // Used by CLI subcommands (send, project add) that need credentials
114
+ // but don't run the interactive wizard.
115
+ // In gateway mode, also sets store.discordBaseUrl so REST calls
116
+ // are routed through the gateway-proxy REST endpoint.
117
+ async function resolveBotCredentials({ appIdOverride } = {}) {
118
+ // DB first: getBotTokenWithMode() sets store.discordBaseUrl which is
119
+ // required in gateway mode so REST calls route through the proxy.
120
+ // Without this, inherited OTTO_BOT_TOKEN / OTTO_BOT_TOKEN (a gateway
121
+ // credential like clientId:clientSecret) would be sent directly to
122
+ // discord.com → 401.
123
+ const botRow = await getBotTokenWithMode().catch((e) => {
124
+ cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
125
+ return null;
126
+ });
127
+ if (botRow) {
128
+ return { token: botRow.token, appId: appIdOverride || botRow.appId };
129
+ }
130
+ // Fall back to env var for CI/headless deployments with no database
131
+ // Supports both OTTO_BOT_TOKEN (new) and OTTO_BOT_TOKEN (legacy)
132
+ const envToken = process.env.OTTO_BOT_TOKEN || process.env.OTTO_BOT_TOKEN;
133
+ if (envToken) {
134
+ const appId = appIdOverride || appIdFromToken(envToken);
135
+ return { token: envToken, appId };
136
+ }
137
+ cliLogger.error('No bot token found. Set OTTO_BOT_TOKEN env var or run `otto gateway start` first to set up.');
138
+ process.exit(EXIT_NO_RESTART);
139
+ }
140
+ function isThreadChannelType(type) {
141
+ return [
142
+ ChannelType.PublicThread,
143
+ ChannelType.PrivateThread,
144
+ ChannelType.AnnouncementThread,
145
+ ].includes(type);
146
+ }
147
+ async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, }) {
148
+ const discordMaxLength = 2000;
149
+ if (prompt.length <= discordMaxLength) {
150
+ return (await rest.post(Routes.channelMessages(channelId), {
151
+ body: { content: prompt, embeds },
152
+ }));
81
153
  }
82
- // 6. Restart kimaki if needed — but NOT if running inside kimaki
83
- // (kimaki restart kills the current opencode session)
84
- const runningInsideKimaki = !!process.env.KIMAKI;
85
- if (configChanged || installed.length > 0) {
86
- if (runningInsideKimaki) {
87
- console.log("\n⚠ Changes require kimaki restart. Run `kimaki restart` manually when ready.");
154
+ const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
155
+ const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`;
156
+ const tmpDir = path.join(process.cwd(), 'tmp');
157
+ if (!fs.existsSync(tmpDir)) {
158
+ fs.mkdirSync(tmpDir, { recursive: true });
159
+ }
160
+ const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`);
161
+ // Wrap long lines so the file is readable in Discord's preview
162
+ // (Discord doesn't wrap text in file attachments)
163
+ const wrappedPrompt = prompt
164
+ .split('\n')
165
+ .flatMap((line) => {
166
+ if (line.length <= 120) {
167
+ return [line];
88
168
  }
89
- else if (hasKimakiBinary()) {
90
- console.log("Restarting kimaki...");
91
- try {
92
- restartKimaki();
93
- console.log("Kimaki restarted.");
94
- }
95
- catch (err) {
96
- const msg = err instanceof Error ? err.message : String(err);
97
- console.error(`Warning: could not restart kimaki: ${msg}`);
98
- }
169
+ const wrapped = [];
170
+ let remaining = line;
171
+ const maxCol = 120;
172
+ // Only soft-break at a space if it's reasonably close to maxCol,
173
+ // otherwise hard-break to avoid tiny fragments from early spaces
174
+ const minSoftBreak = 90;
175
+ while (remaining.length > maxCol) {
176
+ const lastSpace = remaining.lastIndexOf(' ', maxCol);
177
+ const useSoftBreak = lastSpace >= minSoftBreak;
178
+ const breakAt = useSoftBreak ? lastSpace : maxCol;
179
+ wrapped.push(remaining.slice(0, breakAt));
180
+ // Only consume the separator space on soft breaks
181
+ remaining = useSoftBreak
182
+ ? remaining.slice(breakAt + 1)
183
+ : remaining.slice(breakAt);
184
+ }
185
+ if (remaining.length > 0) {
186
+ wrapped.push(remaining);
187
+ }
188
+ return wrapped;
189
+ })
190
+ .join('\n');
191
+ fs.writeFileSync(tmpFile, wrappedPrompt);
192
+ try {
193
+ const formData = new FormData();
194
+ formData.append('payload_json', JSON.stringify({
195
+ content: summaryContent,
196
+ attachments: [{ id: 0, filename: 'prompt.md' }],
197
+ embeds,
198
+ }));
199
+ const buffer = fs.readFileSync(tmpFile);
200
+ formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md');
201
+ const starterMessageResponse = await fetch(discordApiUrl(`/channels/${channelId}/messages`), {
202
+ method: 'POST',
203
+ headers: {
204
+ Authorization: `Bot ${botToken}`,
205
+ },
206
+ body: formData,
207
+ });
208
+ if (!starterMessageResponse.ok) {
209
+ const error = await starterMessageResponse.text();
210
+ throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
99
211
  }
212
+ return (await starterMessageResponse.json());
213
+ }
214
+ finally {
215
+ fs.unlinkSync(tmpFile);
100
216
  }
101
- console.log("\nDone!");
102
217
  }
103
- async function cmdUpgrade(mode) {
104
- console.log(`Otto upgrade — mode: ${mode}\n`);
105
- const packageNames = Object.keys(MANIFEST.packages);
106
- let didUpgradePackages = false;
107
- if (mode === "stable") {
108
- const upgradePlan = planStableUpgrades(packageNames, getInstalledVersion, MANIFEST.pinned);
109
- if (upgradePlan.length === 0) {
110
- console.log("Nothing to upgrade — already at pinned stable versions.");
111
- }
112
- else {
113
- console.log("Will upgrade:");
114
- for (const { name, current, target } of upgradePlan) {
115
- console.log(` ${name}: ${current ?? "not installed"} → ${target}`);
218
+ function formatRelativeTime(target) {
219
+ const diffMs = target.getTime() - Date.now();
220
+ if (diffMs <= 0) {
221
+ return 'due now';
222
+ }
223
+ const totalSeconds = Math.floor(diffMs / 1000);
224
+ if (totalSeconds < 60) {
225
+ return `${totalSeconds}s`;
226
+ }
227
+ const totalMinutes = Math.floor(totalSeconds / 60);
228
+ if (totalMinutes < 60) {
229
+ return `${totalMinutes}m`;
230
+ }
231
+ const hours = Math.floor(totalMinutes / 60);
232
+ const minutes = totalMinutes % 60;
233
+ if (hours < 24) {
234
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
235
+ }
236
+ const days = Math.floor(hours / 24);
237
+ const remainingHours = hours % 24;
238
+ return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
239
+ }
240
+ function formatTaskScheduleLine(schedule) {
241
+ if (schedule.scheduleKind === 'at') {
242
+ return `one-time at ${schedule.runAt.toISOString()}`;
243
+ }
244
+ return `cron "${schedule.cronExpr}" (${schedule.timezone}) next ${schedule.nextRunAt.toISOString()}`;
245
+ }
246
+ const EXIT_NO_RESTART = 64;
247
+ function canUseInteractivePrompts() {
248
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
249
+ }
250
+ function exitNonInteractiveSetup() {
251
+ cliLogger.error('Setup requires an interactive terminal (TTY) for prompts. Run `otto gateway start` in an interactive shell to complete setup.');
252
+ process.exit(EXIT_NO_RESTART);
253
+ }
254
+ async function resolveTelegramRuntimeConfig({ telegramBotToken, telegramChatId, }) {
255
+ const isInteractive = canUseInteractivePrompts();
256
+ const envTelegramBotToken = process.env.OTTO_TELEGRAM_BOT_TOKEN;
257
+ const envTelegramChatId = process.env.OTTO_TELEGRAM_CHAT_ID;
258
+ const resolvedTelegramBotToken = stripBracketedPaste(telegramBotToken || envTelegramBotToken || '') || (isInteractive
259
+ ? await (async () => {
260
+ const input = await password({
261
+ message: 'Enter Telegram bot token (from @BotFather):',
262
+ validate(value) {
263
+ const cleaned = stripBracketedPaste(value);
264
+ if (!cleaned) {
265
+ return 'Telegram bot token is required';
266
+ }
267
+ },
268
+ });
269
+ if (isCancel(input)) {
270
+ cancel('Setup cancelled');
271
+ process.exit(0);
116
272
  }
117
- for (const { name } of upgradePlan) {
118
- console.log(`Upgrading ${name}...`);
119
- upgradePackage(name, mode);
273
+ return stripBracketedPaste(input);
274
+ })()
275
+ : '');
276
+ const resolvedTelegramChatIdRaw = stripBracketedPaste(telegramChatId || envTelegramChatId || '') || (isInteractive
277
+ ? await (async () => {
278
+ const input = await text({
279
+ message: 'Enter Telegram chat ID (supergroup id):',
280
+ validate(value) {
281
+ const cleaned = stripBracketedPaste(value);
282
+ if (!cleaned) {
283
+ return 'Telegram chat ID is required';
284
+ }
285
+ if (!/^-?\d+$/.test(cleaned)) {
286
+ return 'Telegram chat ID must be numeric';
287
+ }
288
+ },
289
+ });
290
+ if (isCancel(input)) {
291
+ cancel('Setup cancelled');
292
+ process.exit(0);
120
293
  }
121
- }
122
- didUpgradePackages = upgradePlan.length > 0;
294
+ return stripBracketedPaste(input);
295
+ })()
296
+ : '');
297
+ if (!resolvedTelegramBotToken || !resolvedTelegramChatIdRaw) {
298
+ return new Error('Telegram mode requires token and chat id. Provide --telegram-bot-token and --telegram-chat-id (or OTTO_TELEGRAM_BOT_TOKEN and OTTO_TELEGRAM_CHAT_ID).');
123
299
  }
124
- else {
125
- console.log("Will upgrade:");
126
- for (const name of packageNames) {
127
- const current = getInstalledVersion(name);
128
- console.log(` ${name}: ${current ?? "not installed"} → latest`);
129
- }
130
- for (const name of packageNames) {
131
- console.log(`Upgrading ${name}...`);
132
- upgradePackage(name, mode);
133
- }
134
- didUpgradePackages = packageNames.length > 0;
135
- }
136
- // Ensure plugins + Otto policy are in config
137
- const { config, status } = readOpenCodeConfigState();
138
- if (status === "invalid") {
139
- console.error("Error: opencode.json exists but is not valid JSON. Fix the file, then run otto again.");
140
- process.exit(1);
141
- }
142
- // Ensure otto.json exists
143
- const ottoConfig = readOttoConfig();
144
- writeOttoConfig(ottoConfig);
145
- const merged = mergeOttoManagedConfig(config);
146
- const configChanged = writeOpenCodeConfig(merged);
147
- // Ensure skill file is up to date
148
- ensureSubagentThreadSkill();
149
- // Restart — but NOT inside kimaki session
150
- // Only restart if anything actually changed.
151
- const runningInsideKimaki = !!process.env.KIMAKI;
152
- if (didUpgradePackages || configChanged) {
153
- if (runningInsideKimaki) {
154
- console.log("\n⚠ Changes require kimaki restart. Run `kimaki restart` manually when ready.");
155
- }
156
- else if (hasKimakiBinary()) {
157
- console.log("Restarting kimaki...");
158
- try {
159
- restartKimaki();
160
- console.log("Kimaki restarted.");
161
- }
162
- catch (err) {
163
- const msg = err instanceof Error ? err.message : String(err);
164
- console.error(`Warning: could not restart kimaki: ${msg}`);
165
- }
166
- }
167
- }
168
- console.log("\nDone!");
169
- }
170
- async function cmdStatus() {
171
- console.log("Otto status\n");
172
- console.log(`Otto version: ${MANIFEST.version}\n`);
173
- console.log("Packages:");
174
- const packages = checkPackagePresence();
175
- for (const pkg of packages) {
176
- const icon = pkg.status === "ok" ? "✓" : "✗";
177
- console.log(` ${icon} ${pkg.name}: ${pkg.installed ?? "not installed"} (requires ${pkg.required})`);
178
- }
179
- console.log("\nConfig:");
180
- const configHealth = checkConfigHealth();
181
- console.log(` opencode.json: ${configHealth.opencodeJson}`);
182
- console.log(` agent-memory.json: ${configHealth.agentMemoryJson}`);
183
- console.log(` otto.json: ${configHealth.ottoJson}`);
184
- console.log(` plugins: ${configHealth.plugins.length > 0 ? configHealth.plugins.join(", ") : "(none)"}`);
185
- console.log(` memory plugin: ${configHealth.memoryPluginEnabled ? "enabled" : "NOT enabled"}`);
186
- console.log(` subagent threads: ${configHealth.subagentThreadsEnabled ? "enabled" : "disabled"}`);
187
- console.log(` ask before thread delete: ${configHealth.subagentThreadsAskBeforeDelete ? "yes" : "no"}`);
188
- console.log(` auto delete thread on complete: ${configHealth.subagentThreadsAutoDelete ? "yes" : "no"}`);
189
- console.log(` kimaki process: ${configHealth.kimakiRunning ? "running" : "not running"}`);
190
- console.log("\nSkills:");
191
- const skillsInstalled = listInstalledSkills();
192
- console.log(` installed: ${skillsInstalled.length > 0 ? skillsInstalled.map((s) => s.name).join(", ") : "(none)"}`);
193
- try {
194
- const index = loadSkillsIndex();
195
- const repoCount = Object.keys(index.repos).length;
196
- const totalIndexed = getAllIndexedSkills().length;
197
- console.log(` indexed: ${totalIndexed} skills from ${repoCount} repos`);
300
+ const chatId = Number.parseInt(resolvedTelegramChatIdRaw, 10);
301
+ if (!Number.isFinite(chatId)) {
302
+ return new Error('Telegram chat ID must be numeric');
198
303
  }
199
- catch {
200
- console.log(" indexed: (unavailable)");
304
+ return { botToken: resolvedTelegramBotToken, chatId };
305
+ }
306
+ // Emit a structured JSON line on stdout for non-TTY consumers (cloud sandboxes, CI).
307
+ // Each line is a self-contained JSON object with a "type" field for easy parsing.
308
+ // Lines are prefixed with "data: " and terminated with "\n\n" (SSE format) so consumers
309
+ // can use the eventsource-parser npm package to robustly extract JSON events from noisy
310
+ // process output (other log lines, warnings, etc. are ignored by the parser).
311
+ function emitJsonEvent(event) {
312
+ process.stdout.write(`data: ${JSON.stringify(event)}\n\n`);
313
+ }
314
+ async function resolveGatewayInstallCredentials({ gatewayPlatform, }) {
315
+ const appId = getGatewayAppId({ gatewayPlatform });
316
+ if (!appId) {
317
+ return new Error('Gateway mode is not available yet. OTTO_GATEWAY_APP_ID is not configured.');
201
318
  }
319
+ const prisma = await getPrisma();
320
+ const gatewayBot = await prisma.bot_tokens.findUnique({
321
+ where: { app_id: appId },
322
+ });
323
+ if (gatewayBot?.client_id && gatewayBot.client_secret) {
324
+ return {
325
+ appId,
326
+ clientId: gatewayBot.client_id,
327
+ clientSecret: gatewayBot.client_secret,
328
+ createdNow: false,
329
+ };
330
+ }
331
+ const clientId = crypto.randomUUID();
332
+ const clientSecret = crypto.randomBytes(32).toString('hex');
333
+ const gatewayRestBaseUrl = gatewayPlatform === 'telegram'
334
+ ? null
335
+ : getGatewayProxyRestBaseUrlForPlatform({
336
+ gatewayPlatform,
337
+ });
338
+ if (gatewayRestBaseUrl instanceof Error) {
339
+ return gatewayRestBaseUrl;
340
+ }
341
+ await setBotMode({
342
+ appId,
343
+ mode: 'gateway',
344
+ clientId,
345
+ clientSecret,
346
+ proxyUrl: gatewayRestBaseUrl,
347
+ });
348
+ return {
349
+ appId,
350
+ clientId,
351
+ clientSecret,
352
+ createdNow: true,
353
+ };
202
354
  }
203
- async function cmdDoctor() {
204
- console.log("Otto doctor\n");
205
- let hasErrors = false;
206
- console.log("Checking packages...");
207
- const packages = checkPackagePresence();
208
- for (const pkg of packages) {
209
- if (pkg.status === "missing") {
210
- console.log(` ${pkg.name} not installed (requires ${pkg.required})`);
211
- hasErrors = true;
355
+ async function printDiscordInstallUrlAndExit({ gateway, gatewayPlatform = 'discord', gatewayCallbackUrl, } = {}) {
356
+ await initDatabase();
357
+ if (gateway) {
358
+ const gatewayCredentials = await resolveGatewayInstallCredentials({
359
+ gatewayPlatform,
360
+ });
361
+ if (gatewayCredentials instanceof Error) {
362
+ cliLogger.error(`Failed to resolve gateway install URL: ${gatewayCredentials.message}`);
363
+ process.exit(EXIT_NO_RESTART);
212
364
  }
213
- else {
214
- console.log(` ✓ ${pkg.name}: ${pkg.installed}`);
365
+ const installUrl = generateDiscordInstallUrlForBot({
366
+ appId: gatewayCredentials.appId,
367
+ mode: 'gateway',
368
+ clientId: gatewayCredentials.clientId,
369
+ clientSecret: gatewayCredentials.clientSecret,
370
+ gatewayPlatform,
371
+ gatewayCallbackUrl,
372
+ });
373
+ if (installUrl instanceof Error) {
374
+ cliLogger.error(`Failed to build install URL: ${installUrl.message}`);
375
+ process.exit(EXIT_NO_RESTART);
376
+ }
377
+ cliLogger.log(installUrl);
378
+ if (gatewayCredentials.createdNow) {
379
+ cliLogger.log('Generated and saved new local gateway client credentials.');
215
380
  }
381
+ cliLogger.log('This gateway install URL contains your client credentials. Do not share it.');
382
+ process.exit(0);
216
383
  }
217
- console.log("\nChecking config...");
218
- const configHealth = checkConfigHealth();
219
- if (configHealth.opencodeJson === "error") {
220
- console.log(" ✗ opencode.json is not valid JSON — fix syntax, then run `otto install`");
221
- hasErrors = true;
384
+ const existingBot = await getBotTokenWithMode();
385
+ if (!existingBot) {
386
+ cliLogger.error('No bot configured yet. Run `otto gateway start` first to set up.');
387
+ process.exit(EXIT_NO_RESTART);
222
388
  }
223
- if (configHealth.memoryPluginEnabled) {
224
- console.log(" ✓ mempalace plugin enabled");
389
+ const installUrl = generateDiscordInstallUrlForBot({
390
+ appId: existingBot.appId,
391
+ mode: existingBot.mode,
392
+ clientId: existingBot.clientId,
393
+ clientSecret: existingBot.clientSecret,
394
+ });
395
+ if (installUrl instanceof Error) {
396
+ cliLogger.error(`Failed to build install URL: ${installUrl.message}`);
397
+ process.exit(EXIT_NO_RESTART);
225
398
  }
226
- else {
227
- console.log(" ✗ mempalace plugin NOT enabled — run `otto install`");
228
- hasErrors = true;
399
+ cliLogger.log(installUrl);
400
+ if (existingBot.mode === 'gateway') {
401
+ cliLogger.log('This gateway install URL contains your client credentials. Do not share it.');
229
402
  }
230
- if (configHealth.subagentPolicyInjected) {
231
- console.log(" ✓ otto subagent thread policy injected");
403
+ process.exit(0);
404
+ }
405
+ // Detect if a CLI tool is installed, prompt to install if missing.
406
+ // Uses official install scripts with platform-specific commands for Unix vs Windows.
407
+ // Sets process.env[envPathKey] to the found binary path for the current session.
408
+ // After install, re-checks PATH first, then falls back to common install locations.
409
+ async function ensureCommandAvailable({ name, envPathKey, installUnix, installWindows, possiblePathsUnix, possiblePathsWindows, }) {
410
+ if (process.env[envPathKey]) {
411
+ return;
232
412
  }
233
- else {
234
- console.log(" ✗ otto subagent thread policy missing run `otto install`");
235
- hasErrors = true;
413
+ const isWindows = process.platform === 'win32';
414
+ const whichCmd = isWindows ? 'where' : 'which';
415
+ const isInstalled = await execAsync(`${whichCmd} ${name}`, {
416
+ env: process.env,
417
+ }).then(() => {
418
+ return true;
419
+ }, () => {
420
+ return false;
421
+ });
422
+ if (isInstalled) {
423
+ return;
236
424
  }
237
- if (configHealth.kimakiRunning) {
238
- console.log(" ✓ kimaki is running");
425
+ note(`${name} is required but not found in your PATH.`, `${name} Not Found`);
426
+ // In non-TTY (cloud sandbox, CI), auto-install without prompting.
427
+ // In interactive mode, ask the user first.
428
+ if (canUseInteractivePrompts()) {
429
+ const shouldInstall = await confirm({
430
+ message: `Would you like to install ${name} right now?`,
431
+ });
432
+ if (isCancel(shouldInstall) || !shouldInstall) {
433
+ cancel(`${name} is required to run this bot`);
434
+ process.exit(EXIT_NO_RESTART);
435
+ }
239
436
  }
240
437
  else {
241
- console.log(" ⚠ kimaki is not running");
438
+ cliLogger.log(`Auto-installing ${name} (non-interactive mode)...`);
439
+ }
440
+ cliLogger.log(`Installing ${name}...`);
441
+ try {
442
+ // Use explicit shell invocation to avoid Node shell-mode quirks on Windows.
443
+ // PowerShell needs -NoProfile and -ExecutionPolicy Bypass for install scripts.
444
+ // Unix uses login shell (-l) so install scripts can update PATH in shell config.
445
+ const cmd = isWindows ? 'powershell.exe' : '/bin/bash';
446
+ const args = isWindows
447
+ ? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', installWindows]
448
+ : ['-lc', installUnix];
449
+ await new Promise((resolve, reject) => {
450
+ const child = spawn(cmd, args, { stdio: 'inherit', env: process.env });
451
+ child.on('close', (code) => {
452
+ if (code === 0) {
453
+ resolve();
454
+ }
455
+ else {
456
+ reject(new Error(`${name} install exited with code ${code}`));
457
+ }
458
+ });
459
+ child.on('error', reject);
460
+ });
461
+ cliLogger.log(`${name} installed successfully!`);
242
462
  }
243
- console.log("\nChecking directories...");
244
- const dirs = checkDirectoryHealth();
245
- for (const d of dirs) {
246
- const icon = d.status === "ok" ? "✓" : d.status === "warn" ? "⚠" : "✗";
247
- console.log(` ${icon} ${d.name}: ${d.message}`);
248
- if (d.status === "error")
249
- hasErrors = true;
463
+ catch (error) {
464
+ cliLogger.log(`Failed to install ${name}`);
465
+ cliLogger.error('Installation error:', error instanceof Error ? error.stack : String(error));
466
+ process.exit(EXIT_NO_RESTART);
250
467
  }
251
- console.log("\nChecking skills...");
252
- const skillsInstalled = listInstalledSkills();
253
- if (skillsInstalled.length > 0) {
254
- console.log(` ✓ ${skillsInstalled.length} skill(s) installed`);
468
+ // After install, re-check PATH first (install script may have added it)
469
+ const foundInPath = await execAsync(`${whichCmd} ${name}`, {
470
+ env: process.env,
471
+ }).then((result) => {
472
+ const resolved = selectResolvedCommand({
473
+ output: result.stdout,
474
+ isWindows,
475
+ });
476
+ return resolved || '';
477
+ }, () => {
478
+ return '';
479
+ });
480
+ if (foundInPath) {
481
+ process.env[envPathKey] = foundInPath;
482
+ return;
255
483
  }
256
- else {
257
- console.log(" ⚠ No skills installed run `otto skills add --all`");
484
+ // Fall back to probing common install locations
485
+ const home = process.env.HOME || process.env.USERPROFILE || '';
486
+ const accessFlag = isWindows ? fs.constants.F_OK : fs.constants.X_OK;
487
+ const possiblePaths = (isWindows ? possiblePathsWindows : possiblePathsUnix)
488
+ .filter((p) => {
489
+ return !p.startsWith('~') || home;
490
+ })
491
+ .map((p) => {
492
+ return p.replace('~', home);
493
+ });
494
+ const installedPath = possiblePaths.find((p) => {
495
+ try {
496
+ fs.accessSync(p, accessFlag);
497
+ return true;
498
+ }
499
+ catch {
500
+ return false;
501
+ }
502
+ });
503
+ if (!installedPath) {
504
+ note(`${name} was installed but may not be available in this session.\n` +
505
+ 'Please restart your terminal and run this command again.', 'Restart Required');
506
+ process.exit(EXIT_NO_RESTART);
507
+ }
508
+ process.env[envPathKey] = installedPath;
509
+ }
510
+ // Run opencode upgrade in the background so the user always has the latest version.
511
+ // Spawn caffeinate on macOS to prevent system sleep while bot is running.
512
+ // Uses -w to watch the parent PID so caffeinate self-terminates if otto
513
+ // exits for any reason (SIGTERM, crash, process.exit, supervisor stop).
514
+ function startCaffeinate() {
515
+ if (process.platform !== 'darwin') {
516
+ return;
258
517
  }
259
518
  try {
260
- const totalIndexed = getAllIndexedSkills().length;
261
- if (totalIndexed > 0) {
262
- console.log(` ✓ Skills index available (${totalIndexed} skills)`);
519
+ const proc = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
520
+ stdio: 'ignore',
521
+ detached: false,
522
+ });
523
+ proc.unref();
524
+ proc.on('error', (err) => {
525
+ cliLogger.warn('Failed to start caffeinate:', err.message);
526
+ });
527
+ cliLogger.log('Started caffeinate to prevent system sleep');
528
+ }
529
+ catch (err) {
530
+ cliLogger.warn('Failed to spawn caffeinate:', err instanceof Error ? err.message : String(err));
531
+ }
532
+ }
533
+ const cli = goke('otto');
534
+ process.title = 'otto';
535
+ import { store } from './store.js';
536
+ import { registerCommands, SKIP_USER_COMMANDS } from './discord-command-registration.js';
537
+ async function collectOttoChannels({ guilds, }) {
538
+ const guildResults = await Promise.all(guilds.map(async (guild) => {
539
+ const channels = await getChannelsWithDescriptions(guild);
540
+ const ottoChans = channels.filter((ch) => ch.ottoDirectory);
541
+ return { guild, channels: ottoChans };
542
+ }));
543
+ return guildResults.filter((result) => {
544
+ return result.channels.length > 0;
545
+ });
546
+ }
547
+ /**
548
+ * Store channel-directory mappings in the database.
549
+ * Called after Discord login to persist channel configurations.
550
+ */
551
+ async function storeChannelDirectories({ ottoChannels, }) {
552
+ for (const { guild, channels } of ottoChannels) {
553
+ for (const channel of channels) {
554
+ if (channel.ottoDirectory) {
555
+ await setChannelDirectory({
556
+ channelId: channel.id,
557
+ directory: channel.ottoDirectory,
558
+ channelType: 'text',
559
+ skipIfExists: true,
560
+ });
561
+ const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
562
+ if (voiceChannel) {
563
+ await setChannelDirectory({
564
+ channelId: voiceChannel.id,
565
+ directory: channel.ottoDirectory,
566
+ channelType: 'voice',
567
+ skipIfExists: true,
568
+ });
569
+ }
570
+ }
571
+ }
572
+ }
573
+ }
574
+ /**
575
+ * Show the ready message with channel links.
576
+ * Called at the end of startup to display available channels.
577
+ */
578
+ function showReadyMessage({ ottoChannels, createdChannels, gatewayPlatform, }) {
579
+ const allChannels = [];
580
+ allChannels.push(...createdChannels);
581
+ ottoChannels.forEach(({ guild, channels }) => {
582
+ channels.forEach((ch) => {
583
+ allChannels.push({
584
+ name: ch.name,
585
+ id: ch.id,
586
+ guildId: guild.id,
587
+ directory: ch.ottoDirectory,
588
+ });
589
+ });
590
+ });
591
+ if (allChannels.length > 0) {
592
+ if (gatewayPlatform === 'telegram') {
593
+ const channelList = allChannels
594
+ .map((ch) => {
595
+ return `• #${ch.name}`;
596
+ })
597
+ .join('\n');
598
+ note(`Your otto channels are ready!\n\n${channelList}\n\nSend a message in your configured Telegram chat to start using OpenCode!`, '🚀 Ready to Use');
263
599
  }
264
600
  else {
265
- console.log(" ⚠ Skills index empty — run `otto skills update`");
601
+ const channelLinks = allChannels
602
+ .map((ch) => `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
603
+ .join('\n');
604
+ note(`Your otto channels are ready! Click any link below to open in Discord:\n\n${channelLinks}\n\nSend a message in any channel to start using OpenCode!`, '🚀 Ready to Use');
266
605
  }
267
606
  }
268
- catch {
269
- console.log(" ⚠ Skills index unavailable");
270
- }
271
- console.log(hasErrors ? "\n✗ Issues found. Run `otto install` to fix." : "\n✓ All checks passed!");
272
- }
273
- // ---------------------------------------------------------------------------
274
- // otto skills sub-commands
275
- // ---------------------------------------------------------------------------
276
- async function cmdSkills(subArgs) {
277
- const skillCommand = subArgs[0] ?? "";
278
- switch (skillCommand) {
279
- case "list":
280
- cmdSkillsList();
281
- break;
282
- case "search": {
283
- const query = subArgs.slice(1).join(" ");
284
- if (!query) {
285
- console.log("Usage: otto skills search <query>");
286
- process.exit(1);
607
+ note('Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `otto gateway start` again to start the gateway.', '⚠️ Keep Running');
608
+ }
609
+ /**
610
+ * Create the default otto channel in each guild and send a welcome message.
611
+ * Idempotent: skips guilds that already have the channel.
612
+ * Extracted so both the interactive and headless startup paths share the same logic.
613
+ */
614
+ async function ensureDefaultChannelsWithWelcome({ guilds, discordClient, appId, isGatewayMode, installerDiscordUserId, }) {
615
+ const created = [];
616
+ for (const guild of guilds) {
617
+ try {
618
+ const result = await createDefaultOttoChannel({
619
+ guild,
620
+ botName: discordClient.user?.username,
621
+ appId,
622
+ isGatewayMode,
623
+ });
624
+ if (result) {
625
+ created.push({
626
+ name: result.channelName,
627
+ id: result.textChannelId,
628
+ guildId: guild.id,
629
+ });
630
+ // Send welcome message to the newly created default channel.
631
+ // Mention the installer so they get a notification.
632
+ const mentionUserId = installerDiscordUserId || guild.ownerId;
633
+ await sendWelcomeMessage({
634
+ channel: result.textChannel,
635
+ mentionUserId,
636
+ });
287
637
  }
288
- cmdSkillsSearch(query);
289
- break;
290
638
  }
291
- case "browse":
292
- cmdSkillsBrowse();
293
- break;
294
- case "add": {
295
- const arg = subArgs[1];
296
- if (!arg || arg === "--all") {
297
- await cmdSkillsAddAll();
298
- }
299
- else {
300
- await cmdSkillsAddOne(arg);
301
- }
302
- break;
639
+ catch (error) {
640
+ cliLogger.warn(`Failed to create default otto channel in ${guild.name}: ${error instanceof Error ? error.stack : String(error)}`);
303
641
  }
304
- case "remove": {
305
- const name = subArgs[1];
306
- if (!name) {
307
- console.log("Usage: otto skills remove <name>");
308
- process.exit(1);
642
+ }
643
+ return created;
644
+ }
645
+ /**
646
+ * Background initialization for quick start mode.
647
+ * Starts OpenCode server and registers slash commands without blocking bot startup.
648
+ */
649
+ async function backgroundInit({ currentDir, token, appId, guildIds, gatewayPlatform, }) {
650
+ try {
651
+ const shouldRegisterForPlatform = shouldRegisterSlashCommands({
652
+ gatewayPlatform,
653
+ });
654
+ const opencodeResult = await initializeOpencodeForDirectory(currentDir);
655
+ if (opencodeResult instanceof Error) {
656
+ cliLogger.warn('Background OpenCode init failed:', opencodeResult.message);
657
+ if (shouldRegisterForPlatform) {
658
+ // Still try to register basic commands without user commands/agents
659
+ await registerCommands({
660
+ token,
661
+ appId,
662
+ guildIds,
663
+ userCommands: [],
664
+ agents: [],
665
+ });
309
666
  }
310
- cmdSkillsRemove(name);
311
- break;
667
+ return;
668
+ }
669
+ const getClient = opencodeResult;
670
+ const [userCommands, agents] = await Promise.all([
671
+ getClient()
672
+ .command.list({ directory: currentDir })
673
+ .then((r) => r.data || [])
674
+ .catch((error) => {
675
+ cliLogger.warn('Failed to load user commands during background init:', error instanceof Error ? error.stack : String(error));
676
+ return [];
677
+ }),
678
+ getClient()
679
+ .app.agents({ directory: currentDir })
680
+ .then((r) => r.data || [])
681
+ .catch((error) => {
682
+ cliLogger.warn('Failed to load agents during background init:', error instanceof Error ? error.stack : String(error));
683
+ return [];
684
+ }),
685
+ ]);
686
+ if (shouldRegisterForPlatform) {
687
+ await registerCommands({ token, appId, guildIds, userCommands, agents });
688
+ cliLogger.log('Slash commands registered!');
689
+ }
690
+ else {
691
+ const platformName = getGatewayPlatformDisplayName({ gatewayPlatform });
692
+ cliLogger.log(`Skipping slash command registration in ${platformName} gateway mode`);
312
693
  }
313
- case "update":
314
- cmdSkillsUpdate();
315
- break;
316
- case "repos":
317
- cmdSkillsRepos();
318
- break;
319
- default:
320
- console.log(`Otto skills — discover and install agent skills from public repos
321
-
322
- Usage:
323
- otto skills search <query> Search skills by name/description
324
- otto skills browse Browse all available skills
325
- otto skills list List installed skills
326
- otto skills add <name> Install a skill
327
- otto skills add --all Install all skills from otto-assistant/skills
328
- otto skills update Refresh skills index from GitHub
329
- otto skills remove <name> Remove an installed skill
330
- otto skills repos Show configured skill repositories
331
- `);
332
- break;
694
+ }
695
+ catch (error) {
696
+ cliLogger.error('Background init failed:', error instanceof Error ? error.stack : String(error));
697
+ void notifyError(error, 'Background init failed');
333
698
  }
334
699
  }
335
- function cmdSkillsSearch(query) {
336
- console.log(`Searching: "${query}"\n`);
337
- const { refreshed } = ensureSkillsIndex();
338
- if (refreshed > 0) {
339
- console.log(`Updated index (${refreshed} repo(s) refreshed).\n`);
700
+ // Resolve bot credentials from (in priority order):
701
+ // 1. OTTO_BOT_TOKEN env var (headless/CI deployments)
702
+ // 2. Saved credentials in the database (self-hosted or gateway mode)
703
+ // 3. Interactive wizard (gateway OAuth or self-hosted token entry)
704
+ //
705
+ // credentialSource tells the caller how creds were obtained:
706
+ // 'env' — OTTO_BOT_TOKEN env var
707
+ // 'saved' — reused from database
708
+ // 'wizard' — user just completed onboarding (gateway OAuth or self-hosted)
709
+ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewayPlatform, gatewayCallbackUrl, telegramBotToken, telegramChatId, }) {
710
+ const envToken = process.env.OTTO_BOT_TOKEN;
711
+ const existingBot = await getBotTokenWithMode();
712
+ // When --gateway is requested and the resolved bot is still self-hosted,
713
+ // check if saved gateway credentials exist by looking up the gateway app_id
714
+ // directly. This lets users switch back and forth between modes without
715
+ // re-running the onboarding wizard each time.
716
+ const gatewayAppId = getGatewayAppId({ gatewayPlatform });
717
+ const hasGatewayCreds = (forceGateway && existingBot?.mode !== 'gateway')
718
+ ? await (await getPrisma()).bot_tokens.findUnique({
719
+ where: { app_id: gatewayAppId },
720
+ })
721
+ : undefined;
722
+ // 1. Env var takes precedence (headless deployments)
723
+ if (envToken && !forceRestartOnboarding && !forceGateway) {
724
+ const derivedAppId = appIdFromToken(envToken);
725
+ if (!derivedAppId) {
726
+ cliLogger.error('Could not derive Application ID from OTTO_BOT_TOKEN. The token appears malformed.');
727
+ process.exit(EXIT_NO_RESTART);
728
+ }
729
+ await setBotToken(derivedAppId, envToken);
730
+ cliLogger.log(`Using OTTO_BOT_TOKEN env var (App ID: ${derivedAppId})`);
731
+ return { appId: derivedAppId, token: envToken, credentialSource: 'env', isGatewayMode: false };
340
732
  }
341
- const results = searchSkills(query);
342
- if (results.length === 0) {
343
- console.log("No skills found.");
344
- return;
733
+ // 2. Saved credentials in the database
734
+ // Reuse saved creds unless: --restart-onboarding forces re-setup, or --gateway
735
+ // overrides saved self-hosted creds (saved gateway creds are still used).
736
+ const canReuseSavedCreds = existingBot && !forceRestartOnboarding
737
+ && !(forceGateway && existingBot.mode !== 'gateway');
738
+ if (canReuseSavedCreds) {
739
+ const modeLabel = existingBot.mode === 'gateway' ? ' (gateway mode)' : '';
740
+ note(`Using saved bot credentials${modeLabel}:\nApp ID: ${existingBot.appId}\n\nTo use different credentials, run with --restart-onboarding`, 'Existing Bot Found');
741
+ if (existingBot.mode !== 'gateway') {
742
+ note(`Bot install URL (in case you need to add it to another server):\n${generateBotInstallUrl({ clientId: existingBot.appId })}`, 'Install URL');
743
+ }
744
+ return { appId: existingBot.appId, token: existingBot.token, credentialSource: 'saved', isGatewayMode: existingBot.mode === 'gateway' };
345
745
  }
346
- for (const skill of results) {
347
- console.log(` ${skill.name} ${skill.description}`);
348
- console.log(` source: ${skill.source}`);
746
+ // 2b. Switching to gateway: saved gateway credentials exist from a previous
747
+ // gateway setup. Reuse them without re-running the onboarding wizard.
748
+ if (hasGatewayCreds && !forceRestartOnboarding) {
749
+ const gatewayToken = (hasGatewayCreds.client_id && hasGatewayCreds.client_secret)
750
+ ? `${hasGatewayCreds.client_id}:${hasGatewayCreds.client_secret}`
751
+ : hasGatewayCreds.token;
752
+ note(`Switching to saved gateway credentials:\nApp ID: ${hasGatewayCreds.app_id}`, 'Mode Switch');
753
+ return {
754
+ appId: hasGatewayCreds.app_id,
755
+ token: gatewayToken,
756
+ credentialSource: 'saved',
757
+ isGatewayMode: true,
758
+ };
349
759
  }
350
- console.log(`\n${results.length} skill(s) found. Install with: otto skills add <name>`);
351
- }
352
- function cmdSkillsBrowse() {
353
- console.log("Otto skills browsing all available\n");
354
- const { refreshed } = ensureSkillsIndex();
355
- if (refreshed > 0) {
356
- console.log(`Updated index (${refreshed} repo(s) refreshed).\n`);
760
+ // 3. Interactive setup wizard (first-time users, --restart-onboarding, or --gateway override).
761
+ // Non-TTY: gateway mode proceeds headlessly (JSON events on stdout),
762
+ // self-hosted mode requires interactive prompts so we exit.
763
+ if (!canUseInteractivePrompts() && !forceGateway) {
764
+ exitNonInteractiveSetup();
765
+ }
766
+ if (existingBot && forceGateway && existingBot.mode !== 'gateway') {
767
+ note('Ignoring saved self-hosted credentials due to --gateway flag.\nSwitching to gateway mode.', 'Gateway Mode');
357
768
  }
358
- const allSkills = getAllIndexedSkills();
359
- const installed = new Set(listInstalledSkills().map((s) => s.name));
360
- // Group by source repo
361
- const byRepo = {};
362
- for (const skill of allSkills) {
363
- if (!byRepo[skill.source])
364
- byRepo[skill.source] = [];
365
- byRepo[skill.source].push(skill);
769
+ else if (forceRestartOnboarding && existingBot) {
770
+ note('Ignoring saved credentials due to --restart-onboarding flag', 'Restart Onboarding');
366
771
  }
367
- for (const [repo, skills] of Object.entries(byRepo)) {
368
- console.log(`${repo} (${skills.length} skills):`);
369
- for (const skill of skills) {
370
- const icon = installed.has(skill.name) ? "✓" : "•";
371
- console.log(` ${icon} ${skill.name} ${skill.description}`);
772
+ // When --gateway is passed or we're in non-TTY mode, skip the mode selector.
773
+ // Non-TTY without --gateway was already rejected above.
774
+ const modeChoice = forceGateway
775
+ ? 'gateway'
776
+ : await (async () => {
777
+ const choice = await select({
778
+ message: 'How do you want to connect to Discord?\n\nGateway: uses Otto\'s pre-built bot — no setup, instant. Self-hosted: you create your own Discord bot at discord.com/developers.',
779
+ options: [
780
+ {
781
+ value: 'gateway',
782
+ disabled: true,
783
+ label: 'Gateway (pre-built Otto bot, currently disabled because of Discord verification process. will be re-enabled soon)',
784
+ },
785
+ {
786
+ value: 'self_hosted',
787
+ label: 'Self-hosted (your own Discord bot, 5-10 min setup)',
788
+ },
789
+ ],
790
+ });
791
+ if (isCancel(choice)) {
792
+ cancel('Setup cancelled');
793
+ process.exit(0);
794
+ }
795
+ return choice;
796
+ })();
797
+ // ── Gateway mode flow ──
798
+ if (modeChoice === 'gateway') {
799
+ if (!gatewayAppId) {
800
+ cliLogger.error('Gateway mode is not available yet. OTTO_GATEWAY_APP_ID is not configured.');
801
+ process.exit(EXIT_NO_RESTART);
802
+ }
803
+ const gatewayCredentials = await resolveGatewayInstallCredentials({
804
+ gatewayPlatform,
805
+ });
806
+ if (gatewayCredentials instanceof Error) {
807
+ throw gatewayCredentials;
808
+ }
809
+ const { appId, clientId, clientSecret } = gatewayCredentials;
810
+ const oauthUrlResult = generateDiscordInstallUrlForBot({
811
+ appId,
812
+ mode: 'gateway',
813
+ clientId,
814
+ clientSecret,
815
+ gatewayPlatform,
816
+ gatewayCallbackUrl,
817
+ reachableUrl: getInternetReachableBaseUrl() || undefined,
818
+ });
819
+ if (oauthUrlResult instanceof Error) {
820
+ throw oauthUrlResult;
821
+ }
822
+ const oauthUrl = oauthUrlResult;
823
+ const isInteractive = canUseInteractivePrompts();
824
+ const platformName = getGatewayPlatformDisplayName({ gatewayPlatform });
825
+ const installTargetName = getGatewayInstallTargetName({ gatewayPlatform });
826
+ if (gatewayPlatform === 'telegram') {
827
+ const telegramConfig = await resolveTelegramRuntimeConfig({
828
+ telegramBotToken,
829
+ telegramChatId,
830
+ });
831
+ if (telegramConfig instanceof Error) {
832
+ cliLogger.error(telegramConfig.message);
833
+ process.exit(EXIT_NO_RESTART);
834
+ }
835
+ const resolvedTelegramBotToken = telegramConfig.botToken;
836
+ const resolvedTelegramChatId = telegramConfig.chatId.toString();
837
+ const installResponse = await fetch(new URL('/api/telegram/install', OTTO_WEBSITE_URL), {
838
+ method: 'POST',
839
+ headers: { 'content-type': 'application/json' },
840
+ body: JSON.stringify({
841
+ client_id: clientId,
842
+ secret: clientSecret,
843
+ bot_token: resolvedTelegramBotToken,
844
+ chat_id: resolvedTelegramChatId,
845
+ }),
846
+ }).catch((cause) => {
847
+ return new Error('Failed to call Telegram install API', { cause });
848
+ });
849
+ if (installResponse instanceof Error) {
850
+ throw installResponse;
851
+ }
852
+ if (!installResponse.ok) {
853
+ const details = await installResponse.text().catch(() => {
854
+ return 'unknown_error';
855
+ });
856
+ throw new Error(`Telegram install failed: ${details}`);
857
+ }
858
+ if (!isInteractive) {
859
+ emitJsonEvent({ type: 'authorized', guild_id: resolvedTelegramChatId });
860
+ }
861
+ return {
862
+ appId,
863
+ token: `${clientId}:${clientSecret}`,
864
+ credentialSource: 'wizard',
865
+ isGatewayMode: true,
866
+ };
867
+ }
868
+ if (isInteractive) {
869
+ note(`Open this URL to install Otto in your ${platformName} ${installTargetName}:\n\n${oauthUrl}\n\nDo not share this URL with anyone — it contains your credentials.${gatewayPlatform === 'discord' ? "\n\nIf you don't have a server, create one first (+ button in Discord sidebar)." : ''}`, 'Install Otto');
870
+ // Open URL in default browser without shell interpolation.
871
+ const openUrlCommand = buildOpenUrlCommand({
872
+ platform: process.platform,
873
+ url: oauthUrl,
874
+ });
875
+ const spawnOptions = process.platform === 'win32'
876
+ ? { stdio: 'ignore', detached: true, shell: false, windowsHide: true }
877
+ : { stdio: 'ignore', detached: true, shell: false };
878
+ const openProcess = spawn(openUrlCommand.command, openUrlCommand.args, spawnOptions);
879
+ openProcess.on('error', (error) => {
880
+ cliLogger.warn(`[onboard] failed to open browser automatically: ${formatErrorWithStack(error)}`);
881
+ });
882
+ openProcess.unref();
883
+ }
884
+ else {
885
+ // Non-TTY: emit structured JSON so the host process can show the URL to the user.
886
+ emitJsonEvent({ type: 'install_url', url: oauthUrl });
887
+ }
888
+ // Poll until the user installs the bot in a Discord server.
889
+ // 100 attempts x 3s = 5 minutes timeout.
890
+ const s = isInteractive ? spinner() : undefined;
891
+ s?.start(`Waiting for a ${platformName} ${installTargetName} with Otto installed...`);
892
+ const pollUrl = new URL('/api/onboarding/status', OTTO_WEBSITE_URL);
893
+ pollUrl.searchParams.set('client_id', clientId);
894
+ pollUrl.searchParams.set('secret', clientSecret);
895
+ let guildId;
896
+ let installerDiscordUserId;
897
+ for (let attempt = 0; attempt < 100; attempt++) {
898
+ await new Promise((resolve) => {
899
+ setTimeout(resolve, 3000);
900
+ });
901
+ // Progressive hints for interactive users who may be stuck
902
+ if (isInteractive) {
903
+ if (attempt === 15) {
904
+ s?.message('Still waiting... Select a server in the authorization page and click "Authorize"');
905
+ }
906
+ else if (attempt === 45) {
907
+ s?.message(`Still waiting... If you don't see any servers, create one first (+ button in Discord sidebar), then reopen the URL above`);
908
+ }
909
+ else if (attempt === 150) {
910
+ s?.message(`Still waiting... Reopen the install URL if you closed it:\n${oauthUrl}`);
911
+ }
912
+ }
913
+ try {
914
+ const resp = await fetch(pollUrl.toString());
915
+ if (resp.ok) {
916
+ const data = (await resp.json());
917
+ if (data.guild_id) {
918
+ guildId = data.guild_id;
919
+ installerDiscordUserId = data.discord_user_id;
920
+ break;
921
+ }
922
+ }
923
+ }
924
+ catch {
925
+ // Network error, retry
926
+ }
372
927
  }
373
- console.log();
928
+ if (!guildId) {
929
+ if (isInteractive) {
930
+ s?.stop('Authorization timed out');
931
+ }
932
+ else {
933
+ emitJsonEvent({ type: 'error', message: `${platformName} authorization timed out after 5 minutes` });
934
+ }
935
+ cliLogger.error(`${platformName} authorization timed out after 5 minutes. Please try again.`);
936
+ process.exit(EXIT_NO_RESTART);
937
+ }
938
+ if (isInteractive) {
939
+ s?.stop('Bot authorized successfully!');
940
+ const syncSpinner = spinner();
941
+ syncSpinner.start('Waiting for gateway sync...');
942
+ await new Promise((resolve) => {
943
+ setTimeout(resolve, 2000);
944
+ });
945
+ syncSpinner.stop('Gateway sync completed');
946
+ }
947
+ else {
948
+ emitJsonEvent({ type: 'authorized', guild_id: guildId });
949
+ await new Promise((resolve) => {
950
+ setTimeout(resolve, 2000);
951
+ });
952
+ }
953
+ return {
954
+ appId,
955
+ token: `${clientId}:${clientSecret}`,
956
+ credentialSource: 'wizard',
957
+ isGatewayMode: true,
958
+ installerDiscordUserId,
959
+ };
960
+ }
961
+ // ── Self-hosted mode flow (existing wizard) ──
962
+ note('1. Go to https://discord.com/developers/applications\n' +
963
+ '2. Click "New Application"\n' +
964
+ '3. Give your application a name', 'Step 1: Create Discord Application');
965
+ note('1. Go to the "Bot" section in the left sidebar\n' +
966
+ '2. Scroll down to "Privileged Gateway Intents"\n' +
967
+ '3. Enable these intents by toggling them ON:\n' +
968
+ ' • SERVER MEMBERS INTENT\n' +
969
+ ' • MESSAGE CONTENT INTENT\n' +
970
+ '4. Click "Save Changes" at the bottom', 'Step 2: Enable Required Intents');
971
+ const intentsConfirmed = await text({
972
+ message: 'Press Enter after enabling both intents:',
973
+ placeholder: 'Enter',
974
+ });
975
+ if (isCancel(intentsConfirmed)) {
976
+ cancel('Setup cancelled');
977
+ process.exit(0);
374
978
  }
375
- const totalAvailable = allSkills.filter((s) => !installed.has(s.name)).length;
376
- console.log(`${allSkills.length} total, ${totalAvailable} available to install.`);
979
+ note('1. Still in the "Bot" section\n' +
980
+ '2. Click "Reset Token" to generate a new bot token (in case of errors try again)\n' +
981
+ "3. Copy the token (you won't be able to see it again!)", 'Step 3: Get Bot Token');
982
+ const tokenInput = await password({
983
+ message: 'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
984
+ validate(value) {
985
+ const cleaned = stripBracketedPaste(value);
986
+ if (!cleaned) {
987
+ return 'Bot token is required';
988
+ }
989
+ if (cleaned.length < 50) {
990
+ return 'Invalid token format (too short)';
991
+ }
992
+ },
993
+ });
994
+ if (isCancel(tokenInput)) {
995
+ cancel('Setup cancelled');
996
+ process.exit(0);
997
+ }
998
+ const wizardToken = stripBracketedPaste(tokenInput);
999
+ const derivedAppId = appIdFromToken(wizardToken);
1000
+ if (!derivedAppId) {
1001
+ cliLogger.error('Could not derive Application ID from the bot token. The token appears malformed.');
1002
+ process.exit(EXIT_NO_RESTART);
1003
+ }
1004
+ await setBotToken(derivedAppId, wizardToken);
1005
+ note(`Bot install URL:\n${generateBotInstallUrl({ clientId: derivedAppId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
1006
+ const installed = await text({
1007
+ message: 'Press Enter AFTER you have installed the bot in your server:',
1008
+ placeholder: 'Enter',
1009
+ });
1010
+ if (isCancel(installed)) {
1011
+ cancel('Setup cancelled');
1012
+ process.exit(0);
1013
+ }
1014
+ return { appId: derivedAppId, token: wizardToken, credentialSource: 'wizard', isGatewayMode: false };
377
1015
  }
378
- function cmdSkillsList() {
379
- console.log("Otto skills\n");
380
- const installed = listInstalledSkills();
381
- if (installed.length > 0) {
382
- console.log("Installed:");
383
- for (const s of installed) {
384
- console.log(` ✓ ${s.name} ${s.description}`);
1016
+ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceChannels, gateway, gatewayPlatform, gatewayCallbackUrl, telegramBotToken, telegramChatId, }) {
1017
+ startCaffeinate();
1018
+ const forceRestartOnboarding = Boolean(restartOnboarding);
1019
+ const forceGateway = Boolean(gateway);
1020
+ const resolvedGatewayPlatform = parseGatewayPlatform(gatewayPlatform);
1021
+ // Step 0: Ensure required CLI tools are installed (OpenCode + Bun).
1022
+ // Run checks in parallel since they're independent `which` calls.
1023
+ await Promise.all([
1024
+ ensureCommandAvailable({
1025
+ name: 'opencode',
1026
+ envPathKey: 'OPENCODE_PATH',
1027
+ installUnix: 'curl -fsSL https://opencode.ai/install | bash',
1028
+ installWindows: 'irm https://opencode.ai/install.ps1 | iex',
1029
+ possiblePathsUnix: [
1030
+ '~/.local/bin/opencode',
1031
+ '~/.opencode/bin/opencode',
1032
+ '/usr/local/bin/opencode',
1033
+ '/opt/opencode/bin/opencode',
1034
+ ],
1035
+ possiblePathsWindows: [
1036
+ '~\\.local\\bin\\opencode.exe',
1037
+ '~\\AppData\\Local\\opencode\\opencode.exe',
1038
+ '~\\.opencode\\bin\\opencode.exe',
1039
+ ],
1040
+ }),
1041
+ ensureCommandAvailable({
1042
+ name: 'bun',
1043
+ envPathKey: 'BUN_PATH',
1044
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
1045
+ installWindows: 'irm bun.sh/install.ps1 | iex',
1046
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1047
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1048
+ }),
1049
+ ]);
1050
+ backgroundUpgradeOtto();
1051
+ // Start in-process Hrana server before database init. Required for the bot
1052
+ // process because it serves as both the DB server and the single-instance
1053
+ // lock (binds the fixed lock port). Without it, IPC and lock enforcement
1054
+ // don't work. CLI subcommands skip the server and use file: directly.
1055
+ const hranaResult = await startHranaServer({
1056
+ dbPath: path.join(getDataDir(), 'discord-sessions.db'),
1057
+ bindAll: getInternetReachableBaseUrl() !== null,
1058
+ });
1059
+ if (hranaResult instanceof Error) {
1060
+ cliLogger.error('Failed to start hrana server:', hranaResult.message);
1061
+ process.exit(EXIT_NO_RESTART);
1062
+ }
1063
+ // Initialize database (connects to hrana server via HTTP)
1064
+ await initDatabase();
1065
+ const { appId, token, credentialSource, isGatewayMode, installerDiscordUserId } = await resolveCredentials({
1066
+ forceRestartOnboarding,
1067
+ forceGateway,
1068
+ gatewayPlatform: resolvedGatewayPlatform,
1069
+ gatewayCallbackUrl,
1070
+ telegramBotToken,
1071
+ telegramChatId,
1072
+ });
1073
+ const selectedPlatformName = getGatewayPlatformDisplayName({
1074
+ gatewayPlatform: resolvedGatewayPlatform,
1075
+ });
1076
+ const selectedInstallTargetName = getGatewayInstallTargetName({
1077
+ gatewayPlatform: resolvedGatewayPlatform,
1078
+ });
1079
+ const runtimeLabel = isGatewayMode && resolvedGatewayPlatform === 'telegram'
1080
+ ? 'Telegram gateway runtime'
1081
+ : 'Discord bot';
1082
+ const gatewayToken = await ensureServiceAuthToken({
1083
+ appId,
1084
+ preferredGatewayToken: isGatewayMode ? token : undefined,
1085
+ });
1086
+ // Always set service auth token so local and internet control-plane paths
1087
+ // share one auth model (/otto/wake and future service endpoints). (/otto/wake is kept as infrastructure endpoint)
1088
+ store.setState({ gatewayToken });
1089
+ let telegramBridge = null;
1090
+ if (isGatewayMode && resolvedGatewayPlatform === 'telegram') {
1091
+ const telegramConfig = await resolveTelegramRuntimeConfig({
1092
+ telegramBotToken,
1093
+ telegramChatId,
1094
+ });
1095
+ if (telegramConfig instanceof Error) {
1096
+ cliLogger.error(telegramConfig.message);
1097
+ process.exit(EXIT_NO_RESTART);
1098
+ }
1099
+ const telegramBridgeSpecifier = 'discord-telegram-bridge';
1100
+ const telegramBridgeModule = await import(telegramBridgeSpecifier).catch((error) => {
1101
+ return new Error('Failed to load optional telegram bridge module', { cause: error });
1102
+ });
1103
+ if (telegramBridgeModule instanceof Error) {
1104
+ cliLogger.error(`${telegramBridgeModule.message}: ${telegramBridgeModule.cause instanceof Error ? telegramBridgeModule.cause.message : String(telegramBridgeModule.cause)}`);
1105
+ process.exit(EXIT_NO_RESTART);
1106
+ }
1107
+ if (!isTelegramBridgeModule(telegramBridgeModule)) {
1108
+ cliLogger.error('Failed to load optional telegram bridge module: invalid module shape');
1109
+ process.exit(EXIT_NO_RESTART);
385
1110
  }
1111
+ telegramBridge = new telegramBridgeModule.TelegramBridge({
1112
+ botToken: telegramConfig.botToken,
1113
+ chatId: telegramConfig.chatId,
1114
+ port: 0,
1115
+ discordToken: token,
1116
+ authorize: async ({ token: incomingToken }) => {
1117
+ return {
1118
+ allow: incomingToken === token,
1119
+ clientId: appId,
1120
+ };
1121
+ },
1122
+ });
1123
+ try {
1124
+ await telegramBridge.start();
1125
+ }
1126
+ catch (error) {
1127
+ cliLogger.error(`Failed to start local Telegram bridge: ${error instanceof Error ? error.message : String(error)}`);
1128
+ process.exit(EXIT_NO_RESTART);
1129
+ }
1130
+ store.setState({
1131
+ discordBaseUrl: telegramBridge.restUrl,
1132
+ });
1133
+ cliLogger.log(`Local Telegram bridge started at ${telegramBridge.restUrl}`);
1134
+ const stopBridge = () => {
1135
+ if (!telegramBridge) {
1136
+ return;
1137
+ }
1138
+ const bridgeToStop = telegramBridge;
1139
+ telegramBridge = null;
1140
+ void bridgeToStop.stop().catch((error) => {
1141
+ cliLogger.warn(`Failed to stop Telegram bridge cleanly: ${error instanceof Error ? error.message : String(error)}`);
1142
+ });
1143
+ };
1144
+ process.once('SIGINT', stopBridge);
1145
+ process.once('SIGTERM', stopBridge);
1146
+ process.once('beforeExit', stopBridge);
386
1147
  }
387
- else {
388
- console.log("Installed: (none)");
389
- }
390
- console.log(`\nUse "otto skills browse" to see all available skills.`);
391
- console.log(`Use "otto skills search <query>" to search.`);
392
- }
393
- async function cmdSkillsAddOne(name) {
394
- console.log(`Installing skill: ${name}\n`);
395
- ensureSkillsIndex();
396
- const success = installSkillFromIndex(name);
397
- if (!success) {
398
- console.error(`Error: skill "${name}" not found. Run "otto skills search <query>" to find skills.`);
399
- process.exit(1);
400
- }
401
- console.log(`Installed ${name} → ~/.config/opencode/skills/${name}/`);
402
- console.log("Done!");
403
- }
404
- async function cmdSkillsAddAll() {
405
- console.log("Installing all skills from otto-assistant/skills...\n");
406
- ensureSkillsIndex();
407
- const allSkills = getAllIndexedSkills();
408
- const ottoSkills = allSkills.filter((s) => s.source === "otto-assistant/skills");
409
- if (ottoSkills.length === 0) {
410
- console.log("No skills found in otto-assistant/skills. Check your connection.");
411
- return;
1148
+ // In gateway mode, ensure REST calls route through the selected gateway.
1149
+ // Telegram uses a local in-process bridge so no external host config is needed.
1150
+ if (isGatewayMode && credentialSource === 'wizard' && resolvedGatewayPlatform !== 'telegram') {
1151
+ const gatewayRestBaseUrl = getGatewayProxyRestBaseUrlForPlatform({
1152
+ gatewayPlatform: resolvedGatewayPlatform,
1153
+ });
1154
+ if (gatewayRestBaseUrl instanceof Error) {
1155
+ cliLogger.error(gatewayRestBaseUrl.message);
1156
+ process.exit(EXIT_NO_RESTART);
1157
+ }
1158
+ store.setState({
1159
+ discordBaseUrl: gatewayRestBaseUrl,
1160
+ });
412
1161
  }
413
- const installed = new Set(listInstalledSkills().map((s) => s.name));
414
- let added = 0;
415
- for (const skill of ottoSkills) {
416
- if (installed.has(skill.name)) {
417
- console.log(` ✓ ${skill.name} (already installed)`);
418
- continue;
1162
+ // When OTTO_INTERNET_REACHABLE_URL is set, the hrana server exposes
1163
+ // a /otto/wake endpoint for the gateway-proxy to wake this instance and
1164
+ // wait until discord.js is connected. Keep Discord traffic on the normal
1165
+ // configured base URL (gateway-proxy in gateway mode).
1166
+ if (getInternetReachableBaseUrl()) {
1167
+ cliLogger.log('Internet-reachable mode: enabling /otto/wake endpoint on hrana server');
1168
+ }
1169
+ // Start OpenCode server as early as possible — non-blocking.
1170
+ // All dependencies are met (dataDir, lockPort, gatewayToken, hranaUrl set).
1171
+ // Runs in parallel with last_used_at update, skipChannelSetup check, and
1172
+ // Discord Gateway login so cold start is not blocked by OpenCode spawn.
1173
+ const currentDir = process.cwd();
1174
+ cliLogger.log('Starting OpenCode server...');
1175
+ const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
1176
+ if (result instanceof Error) {
1177
+ throw new Error(result.message);
419
1178
  }
420
- const success = installSkillFromIndex(skill.name);
421
- if (success) {
422
- console.log(` + ${skill.name}`);
423
- added++;
1179
+ cliLogger.log('OpenCode server ready!');
1180
+ return result;
1181
+ });
1182
+ // Prevent unhandled rejection if OpenCode fails before backgroundInit
1183
+ // or the channel setup path awaits it. Errors are handled by the
1184
+ // respective consumers (backgroundInit catches, channel setup re-throws).
1185
+ opencodePromise.catch(() => { });
1186
+ // Mark this bot as the most recently used so subcommands in separate
1187
+ // processes (send, upload-to-discord, project list) pick the correct bot.
1188
+ // getBotTokenWithMode() orders by last_used_at DESC as cross-process
1189
+ // source of truth.
1190
+ await (await getPrisma()).bot_tokens.update({
1191
+ where: { app_id: appId },
1192
+ data: { last_used_at: new Date() },
1193
+ });
1194
+ // skipChannelSetup: when true, skip interactive project/channel selection
1195
+ // and go straight to bot startup. Channel sync happens in the background.
1196
+ //
1197
+ // Skip when: creds came from env/saved (not first-time wizard), OR non-TTY
1198
+ // gateway (headless), OR user didn't pass --add-channels/--restart-onboarding.
1199
+ // Force channel setup when: first-time quick-start with no channels configured
1200
+ // and TTY is available, or user explicitly passed --add-channels.
1201
+ const isHeadlessGateway = isGatewayMode && !canUseInteractivePrompts();
1202
+ const hasConfiguredTextChannels = Boolean(await (await getPrisma()).channel_directories.findFirst({
1203
+ where: { channel_type: 'text' },
1204
+ select: { channel_id: true },
1205
+ }));
1206
+ const skipChannelSetup = isHeadlessGateway || (() => {
1207
+ // Wizard source always shows channel setup (user just completed onboarding)
1208
+ if (credentialSource === 'wizard') {
1209
+ return false;
424
1210
  }
425
- else {
426
- console.log(` ✗ ${skill.name} (failed)`);
1211
+ // Env/saved source: skip unless user explicitly asked for channels
1212
+ if (forceRestartOnboarding || Boolean(addChannels)) {
1213
+ return false;
1214
+ }
1215
+ // First-time quick start with no channels: force setup if TTY is available
1216
+ if (!hasConfiguredTextChannels && canUseInteractivePrompts()) {
1217
+ return false;
427
1218
  }
1219
+ return true;
1220
+ })();
1221
+ cliLogger.log(`Connecting ${runtimeLabel} to ${getDiscordRestApiUrl()}...`);
1222
+ const discordClient = await createDiscordClient();
1223
+ const guilds = [];
1224
+ const ottoChannels = [];
1225
+ const createdChannels = [];
1226
+ try {
1227
+ await new Promise((resolve, reject) => {
1228
+ discordClient.once(Events.ClientReady, async (c) => {
1229
+ // Guild discovery comes from the Gateway WebSocket READY payload, not
1230
+ // from a separate REST fetch. discord.js consumes READY and hydrates
1231
+ // client.guilds.cache from d.guilds. In gateway mode, gateway-proxy
1232
+ // already filters this list to authorized guilds for client_id:secret.
1233
+ // Example payload fragment received over WS:
1234
+ // {
1235
+ // "op": 0,
1236
+ // "t": "READY",
1237
+ // "d": {
1238
+ // "guilds": [
1239
+ // { "id": "123456789012345678", "unavailable": false }
1240
+ // ]
1241
+ // }
1242
+ // }
1243
+ guilds.push(...Array.from(c.guilds.cache.values()));
1244
+ if (isGatewayMode && resolvedGatewayPlatform === 'telegram') {
1245
+ await Promise.all(guilds.map(async (guild) => {
1246
+ await setChannelDirectory({
1247
+ channelId: guild.id,
1248
+ directory: currentDir,
1249
+ channelType: 'text',
1250
+ skipIfExists: true,
1251
+ });
1252
+ }));
1253
+ }
1254
+ if (skipChannelSetup) {
1255
+ resolve(null);
1256
+ return;
1257
+ }
1258
+ // Process guild metadata when setup flow needs channel prompts.
1259
+ const guildResults = await collectOttoChannels({ guilds });
1260
+ // Collect results
1261
+ for (const result of guildResults) {
1262
+ ottoChannels.push(result);
1263
+ }
1264
+ resolve(null);
1265
+ });
1266
+ discordClient.once(Events.Error, reject);
1267
+ discordClient.login(token).catch(reject);
1268
+ });
1269
+ cliLogger.log(`Connected ${runtimeLabel}!`);
1270
+ // Start IPC polling now that Discord client is ready.
1271
+ // Register cleanup on process exit since the shutdown handler lives in discord-bot.ts.
1272
+ await startIpcPolling({ discordClient });
1273
+ process.on('exit', stopIpcPolling);
428
1274
  }
429
- if (added === 0) {
430
- console.log("\nAll skills already installed.");
1275
+ catch (error) {
1276
+ cliLogger.log(`Failed to connect ${runtimeLabel}`, discordClient.ws.gateway);
1277
+ cliLogger.error('Error: ' + (error instanceof Error ? error.stack : String(error)));
1278
+ process.exit(EXIT_NO_RESTART);
431
1279
  }
432
- else {
433
- console.log(`\nInstalled ${added} skill(s).`);
1280
+ await setBotToken(appId, token);
1281
+ // In gateway mode the bot only sees chats/servers the user has installed
1282
+ // it in. Zero guilds means the install callback never completed or the
1283
+ // user removed Otto from all targets — there is nothing the bot can do.
1284
+ if (isGatewayMode && guilds.length === 0) {
1285
+ // Rebuild the install URL from the current credentials so the user can
1286
+ // add the bot to a server without going through the full --restart-onboarding flow.
1287
+ const [clientId, clientSecret] = token.split(':');
1288
+ if (!clientId || !clientSecret) {
1289
+ throw new Error('Malformed gateway token: expected clientId:clientSecret format');
1290
+ }
1291
+ const installUrlResult = generateDiscordInstallUrlForBot({
1292
+ appId: OTTO_GATEWAY_APP_ID,
1293
+ mode: 'gateway',
1294
+ clientId,
1295
+ clientSecret,
1296
+ gatewayPlatform: resolvedGatewayPlatform,
1297
+ });
1298
+ if (installUrlResult instanceof Error) {
1299
+ throw installUrlResult;
1300
+ }
1301
+ const installUrl = installUrlResult;
1302
+ const installTargetName = getGatewayInstallTargetName({
1303
+ gatewayPlatform: resolvedGatewayPlatform,
1304
+ });
1305
+ const platformName = getGatewayPlatformDisplayName({
1306
+ gatewayPlatform: resolvedGatewayPlatform,
1307
+ });
1308
+ if (!canUseInteractivePrompts()) {
1309
+ emitJsonEvent({
1310
+ type: 'error',
1311
+ message: `No ${platformName} ${installTargetName}s found`,
1312
+ install_url: installUrl,
1313
+ });
1314
+ }
1315
+ cliLogger.error(`No ${platformName} ${installTargetName}s found. Otto must be installed in at least one ${installTargetName}.\n` +
1316
+ `Install URL: ${installUrl}\n` +
1317
+ 'Do not share this URL with anyone — it contains your credentials.\n' +
1318
+ `Open the URL above to add Otto to a ${installTargetName}, then run \`otto gateway start\` again.`);
1319
+ discordClient.destroy();
1320
+ process.exit(EXIT_NO_RESTART);
434
1321
  }
435
- console.log("Done!");
436
- }
437
- function cmdSkillsUpdate() {
438
- console.log("Refreshing skills index from GitHub...\n");
439
- const { refreshed, total } = ensureSkillsIndex(0); // force refresh all
440
- if (refreshed === 0 && total === 0) {
441
- console.log("No repos configured.");
442
- return;
1322
+ if (skipChannelSetup) {
1323
+ // Start bot immediately — channel sync happens in the background.
1324
+ cliLogger.log(`Starting ${runtimeLabel}...`);
1325
+ await startDiscordBot({ token, appId, discordClient, useWorktrees });
1326
+ cliLogger.log(`${runtimeLabel} is running!`);
1327
+ // Background channel sync + role reconciliation + default channel creation.
1328
+ // Never blocks ready state.
1329
+ void (async () => {
1330
+ try {
1331
+ const backgroundChannels = await collectOttoChannels({ guilds });
1332
+ await storeChannelDirectories({ ottoChannels: backgroundChannels });
1333
+ cliLogger.log(`Background channel sync completed for ${backgroundChannels.length} guild(s)`);
1334
+ }
1335
+ catch (error) {
1336
+ cliLogger.warn('Background channel sync failed:', error instanceof Error ? error.stack : String(error));
1337
+ }
1338
+ // Create default otto channel + welcome message in each guild.
1339
+ // Runs after channel sync so existing channels are detected correctly.
1340
+ try {
1341
+ await ensureDefaultChannelsWithWelcome({
1342
+ guilds,
1343
+ discordClient,
1344
+ appId,
1345
+ isGatewayMode,
1346
+ installerDiscordUserId,
1347
+ });
1348
+ }
1349
+ catch (error) {
1350
+ cliLogger.warn('Background default channel creation failed:', error instanceof Error ? error.stack : String(error));
1351
+ }
1352
+ })();
1353
+ // Background: OpenCode init + slash command registration (non-blocking)
1354
+ void backgroundInit({
1355
+ currentDir,
1356
+ token,
1357
+ appId,
1358
+ guildIds: guilds.map((guild) => {
1359
+ return guild.id;
1360
+ }),
1361
+ gatewayPlatform: resolvedGatewayPlatform,
1362
+ });
443
1363
  }
444
- console.log(`Refreshed ${refreshed}/${total} repo(s).`);
445
- const allSkills = getAllIndexedSkills();
446
- console.log(`Index now has ${allSkills.length} skills.`);
447
- console.log("Done!");
448
- }
449
- function cmdSkillsRemove(name) {
450
- console.log(`Removing skill: ${name}\n`);
451
- const success = removeSkill(name);
452
- if (!success) {
453
- console.error(`Error: skill "${name}" is not installed.`);
454
- process.exit(1);
455
- }
456
- console.log(`Removed ${name}.`);
457
- console.log("Done!");
458
- }
459
- function cmdSkillsRepos() {
460
- console.log("Configured skill repositories:\n");
461
- const repos = getConfiguredRepos();
462
- for (const repo of repos) {
463
- console.log(` ${repo}`);
464
- }
465
- console.log(`\n${repos.length} repo(s) configured.`);
466
- }
467
- async function cmdTenant(subArgs) {
468
- const tenantCommand = subArgs[0] ?? "";
469
- const tenantPathArg = subArgs[1];
470
- if (tenantCommand === "skills") {
471
- const action = subArgs[1] ?? "";
472
- const pathArg = subArgs[2];
473
- if (action !== "bootstrap" || !pathArg) {
474
- console.log("Usage: otto tenant skills bootstrap <path>");
475
- process.exit(1);
1364
+ else {
1365
+ // ── Channel setup flow ──
1366
+ // Store channel-directory mappings discovered during Discord login.
1367
+ await storeChannelDirectories({ ottoChannels });
1368
+ if (!hasConfiguredTextChannels) {
1369
+ note('No Otto project channels are configured yet. Opening project/channel setup.', 'Channel Setup');
1370
+ }
1371
+ if (ottoChannels.length > 0) {
1372
+ const channelList = ottoChannels
1373
+ .flatMap(({ guild, channels }) => channels.map((ch) => {
1374
+ return `#${ch.name} in ${guild.name}: ${ch.ottoDirectory}`;
1375
+ }))
1376
+ .join('\n');
1377
+ note(channelList, 'Existing Otto Channels');
1378
+ }
1379
+ // Wait for OpenCode, fetch projects, show prompts, create channels if needed
1380
+ cliLogger.log('Waiting for OpenCode server...');
1381
+ const getClient = await opencodePromise;
1382
+ cliLogger.log('Fetching OpenCode data...');
1383
+ // Fetch projects, commands, and agents in parallel
1384
+ const [projects, allUserCommands, allAgents] = await Promise.all([
1385
+ getClient()
1386
+ .project.list()
1387
+ .then((r) => r.data || [])
1388
+ .catch((error) => {
1389
+ cliLogger.log('Failed to fetch projects');
1390
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
1391
+ discordClient.destroy();
1392
+ process.exit(EXIT_NO_RESTART);
1393
+ }),
1394
+ getClient()
1395
+ .command.list({ directory: currentDir })
1396
+ .then((r) => r.data || [])
1397
+ .catch((error) => {
1398
+ cliLogger.warn('Failed to load user commands during setup:', error instanceof Error ? error.stack : String(error));
1399
+ return [];
1400
+ }),
1401
+ getClient()
1402
+ .app.agents({ directory: currentDir })
1403
+ .then((r) => r.data || [])
1404
+ .catch((error) => {
1405
+ cliLogger.warn('Failed to load agents during setup:', error instanceof Error ? error.stack : String(error));
1406
+ return [];
1407
+ }),
1408
+ ]);
1409
+ cliLogger.log(`Found ${projects.length} OpenCode project(s)`);
1410
+ const existingDirs = ottoChannels.flatMap(({ channels }) => channels
1411
+ .filter((ch) => ch.ottoDirectory)
1412
+ .map((ch) => ch.ottoDirectory)
1413
+ .filter(Boolean));
1414
+ const availableProjects = deduplicateByKey(projects.filter((project) => {
1415
+ if (existingDirs.includes(project.worktree)) {
1416
+ return false;
1417
+ }
1418
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
1419
+ return false;
1420
+ }
1421
+ return true;
1422
+ }), (x) => x.worktree);
1423
+ if (availableProjects.length === 0) {
1424
+ note('All OpenCode projects already have channels', 'No New Projects');
1425
+ }
1426
+ if (availableProjects.length > 0) {
1427
+ if (!canUseInteractivePrompts()) {
1428
+ exitNonInteractiveSetup();
1429
+ }
1430
+ const selectedProjects = await multiselect({
1431
+ message: 'Select projects to create channels for:',
1432
+ options: availableProjects.map((project) => ({
1433
+ value: project.id,
1434
+ label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
1435
+ })),
1436
+ required: false,
1437
+ });
1438
+ if (!isCancel(selectedProjects) && selectedProjects.length > 0) {
1439
+ let targetGuild;
1440
+ if (guilds.length === 0) {
1441
+ cliLogger.error(`No ${selectedPlatformName} ${selectedInstallTargetName}s found. Otto must be installed in at least one ${selectedInstallTargetName}.`);
1442
+ process.exit(EXIT_NO_RESTART);
1443
+ }
1444
+ if (guilds.length === 1) {
1445
+ targetGuild = guilds[0];
1446
+ note(`Using ${selectedInstallTargetName}: ${targetGuild.name}`, 'Target Selected');
1447
+ }
1448
+ else {
1449
+ const guildSelection = await multiselect({
1450
+ message: `Select a ${selectedPlatformName} ${selectedInstallTargetName} to create channels in:`,
1451
+ options: guilds.map((guild) => ({
1452
+ value: guild.id,
1453
+ label: `${guild.name} (${guild.memberCount} members)`,
1454
+ })),
1455
+ required: true,
1456
+ maxItems: 1,
1457
+ });
1458
+ if (isCancel(guildSelection)) {
1459
+ cancel('Setup cancelled');
1460
+ process.exit(0);
1461
+ }
1462
+ targetGuild = guilds.find((g) => g.id === guildSelection[0]);
1463
+ }
1464
+ cliLogger.log('Creating Discord channels...');
1465
+ for (const projectId of selectedProjects) {
1466
+ const project = projects.find((p) => p.id === projectId);
1467
+ if (!project)
1468
+ continue;
1469
+ try {
1470
+ const { textChannelId, channelName } = await createProjectChannels({
1471
+ guild: targetGuild,
1472
+ projectDirectory: project.worktree,
1473
+ botName: discordClient.user?.username,
1474
+ enableVoiceChannels,
1475
+ });
1476
+ createdChannels.push({
1477
+ name: channelName,
1478
+ id: textChannelId,
1479
+ guildId: targetGuild.id,
1480
+ });
1481
+ }
1482
+ catch (error) {
1483
+ cliLogger.error(`Failed to create channels for ${path.basename(project.worktree)}:`, error);
1484
+ }
1485
+ }
1486
+ cliLogger.log(`Created ${createdChannels.length} channel(s)`);
1487
+ if (createdChannels.length > 0) {
1488
+ note(createdChannels.map((ch) => `#${ch.name}`).join('\n'), 'Created Channels');
1489
+ }
1490
+ }
476
1491
  }
477
- const tenantPath = path.resolve(pathArg);
478
- const skillsDir = path.join(tenantPath, "memory", "opencode", "skills");
479
- ensureSkillsIndex();
480
- const report = installSkillsBaseline(GENTLEMAN_SKILLS_BASELINE, skillsDir);
481
- console.log(`Skills baseline bootstrap: ${tenantPath}`);
482
- console.log(` Installed: ${report.installed.length > 0 ? report.installed.join(", ") : "(none)"}`);
483
- console.log(` Already present: ${report.alreadyPresent.length > 0 ? report.alreadyPresent.join(", ") : "(none)"}`);
484
- if (report.failed.length > 0) {
485
- console.log(` Failed: ${report.failed.join(", ")}`);
486
- process.exitCode = 2;
1492
+ // Create default otto channel for general-purpose tasks.
1493
+ // Runs for every guild the bot is in, idempotent (skips if already exists).
1494
+ const defaultChannelResults = await ensureDefaultChannelsWithWelcome({
1495
+ guilds,
1496
+ discordClient,
1497
+ appId,
1498
+ isGatewayMode,
1499
+ installerDiscordUserId,
1500
+ });
1501
+ createdChannels.push(...defaultChannelResults);
1502
+ // Log available user commands
1503
+ const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
1504
+ if (registrableCommands.length > 0) {
1505
+ note(`Found ${registrableCommands.length} user-defined command(s)`, 'OpenCode Commands/Skills');
1506
+ }
1507
+ if (shouldRegisterSlashCommands({ gatewayPlatform: resolvedGatewayPlatform })) {
1508
+ cliLogger.log('Registering slash commands asynchronously...');
1509
+ void registerCommands({
1510
+ token,
1511
+ appId,
1512
+ guildIds: guilds.map((guild) => {
1513
+ return guild.id;
1514
+ }),
1515
+ userCommands: allUserCommands,
1516
+ agents: allAgents,
1517
+ })
1518
+ .then(() => {
1519
+ cliLogger.log('Slash commands registered!');
1520
+ })
1521
+ .catch((error) => {
1522
+ cliLogger.error('Failed to register slash commands:', error instanceof Error ? error.stack : String(error));
1523
+ });
487
1524
  }
488
1525
  else {
489
- console.log(" Failed: (none)");
1526
+ const platformName = getGatewayPlatformDisplayName({ gatewayPlatform: resolvedGatewayPlatform });
1527
+ cliLogger.log(`Skipping slash command registration in ${platformName} gateway mode`);
490
1528
  }
491
- return;
1529
+ // Start bot after channel setup is complete so it doesn't handle
1530
+ // messages/interactions while the user is still going through prompts.
1531
+ cliLogger.log(`Starting ${runtimeLabel}...`);
1532
+ await startDiscordBot({ token, appId, discordClient, useWorktrees });
1533
+ cliLogger.log(`${runtimeLabel} is running!`);
1534
+ }
1535
+ // ── Ready ──
1536
+ if (!canUseInteractivePrompts()) {
1537
+ emitJsonEvent({
1538
+ type: 'ready',
1539
+ app_id: appId,
1540
+ guild_ids: guilds.map((g) => { return g.id; }),
1541
+ });
1542
+ }
1543
+ else {
1544
+ showReadyMessage({
1545
+ ottoChannels,
1546
+ createdChannels,
1547
+ gatewayPlatform: resolvedGatewayPlatform,
1548
+ });
1549
+ outro('✨ Bot ready! Listening for messages...');
492
1550
  }
493
- if (!tenantPathArg) {
494
- console.log("Usage: otto tenant <init|up|down|status|logs> <path>");
495
- process.exit(1);
1551
+ }
1552
+ async function runMenuSubcommand({ args, }) {
1553
+ const scriptPath = process.argv[1];
1554
+ if (!scriptPath) {
1555
+ return new Error('Unable to resolve current CLI script path');
496
1556
  }
497
- const tenantPath = path.resolve(tenantPathArg);
498
- switch (tenantCommand) {
499
- case "init": {
500
- const result = ensureTenantScaffold(tenantPath);
501
- if (result.created.length === 0) {
502
- console.log(`Tenant scaffold already exists: ${tenantPath}`);
1557
+ return new Promise((resolve) => {
1558
+ const child = spawn(process.execPath, [scriptPath, ...args], {
1559
+ stdio: 'inherit',
1560
+ env: process.env,
1561
+ });
1562
+ child.once('error', (error) => {
1563
+ resolve(new Error(`Failed to start command: ${error.message}`, { cause: error }));
1564
+ });
1565
+ child.once('close', (code, signal) => {
1566
+ if (code === 0) {
1567
+ resolve();
1568
+ return;
1569
+ }
1570
+ resolve(new Error(`Command failed (${code ?? signal ?? 'unknown'}): otto ${args.join(' ')}`));
1571
+ });
1572
+ });
1573
+ }
1574
+ function spawnDetachedGatewayStart({ extraArgs = [], }) {
1575
+ const scriptPath = process.argv[1];
1576
+ if (!scriptPath) {
1577
+ return new Error('Unable to resolve current CLI script path');
1578
+ }
1579
+ const sanitizedEnv = {
1580
+ ...process.env,
1581
+ OTTO_OPENCODE_PROCESS: undefined,
1582
+ __OTTO_CHILD: undefined,
1583
+ OTTO_GATEWAY_DAEMON: undefined,
1584
+ };
1585
+ const child = spawn(process.execPath, [scriptPath, 'gateway', 'start', ...extraArgs], {
1586
+ stdio: 'ignore',
1587
+ detached: true,
1588
+ env: sanitizedEnv,
1589
+ shell: false,
1590
+ windowsHide: process.platform === 'win32',
1591
+ });
1592
+ child.unref();
1593
+ return child;
1594
+ }
1595
+ const RESTART_LOG_WAIT_MS = 3_500;
1596
+ const RESTART_LOG_POLL_MS = 200;
1597
+ const RESTART_LOG_MAX_LINES = 24;
1598
+ function resolveGatewayDataDir({ dataDir, }) {
1599
+ if (!dataDir) {
1600
+ return getDataDir();
1601
+ }
1602
+ return path.resolve(dataDir);
1603
+ }
1604
+ async function printGatewayStartupLogs({ dataDir, }) {
1605
+ const logPath = path.join(dataDir, 'otto.log');
1606
+ let linesPrinted = 0;
1607
+ const seenLineKeys = new Set();
1608
+ const deadline = Date.now() + RESTART_LOG_WAIT_MS;
1609
+ while (Date.now() < deadline && linesPrinted < RESTART_LOG_MAX_LINES) {
1610
+ if (fs.existsSync(logPath)) {
1611
+ const content = fs.readFileSync(logPath, 'utf8');
1612
+ const lines = content
1613
+ .split(/\r?\n/)
1614
+ .map((entry) => {
1615
+ return entry.trim();
1616
+ })
1617
+ .filter((entry) => {
1618
+ return entry.length > 0;
1619
+ });
1620
+ for (const [index, line] of lines.entries()) {
1621
+ const lineKey = `${index}:${line}`;
1622
+ if (seenLineKeys.has(lineKey)) {
1623
+ continue;
1624
+ }
1625
+ seenLineKeys.add(lineKey);
1626
+ cliLogger.log(`[gateway] ${line}`);
1627
+ linesPrinted += 1;
1628
+ if (linesPrinted >= RESTART_LOG_MAX_LINES) {
1629
+ return;
1630
+ }
503
1631
  }
504
- else {
505
- console.log(`Tenant scaffold ready: ${tenantPath}`);
506
- console.log(`Created: ${result.created.join(", ")}`);
507
- }
508
- console.log(`
509
- Next steps — get your tenant running in 3 steps:
510
-
511
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
512
- Step 1: Create a Discord Bot
513
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
514
-
515
- 1. Open https://discord.com/developers/applications
516
- 2. Click "New Application" → give it a name → Create
517
- 3. Go to "Bot" tab → Click "Reset Token" → Copy the token
518
- 4. Under "Privileged Gateway Intents" enable:
519
- ✅ Message Content Intent
520
- ✅ Server Members Intent (optional)
521
- 5. Go to "OAuth2" tab → "URL Generator"
522
- Scopes: bot
523
- Permissions: Send Messages, Read Message History, Add Reactions
524
- 6. Open the generated URL → add bot to your Discord server
525
-
526
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
527
- Step 2: Configure your tenant
528
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
529
-
530
- Edit ${tenantPath}/.env with your bot token:
531
-
532
- KIMAKI_BOT_TOKEN=your-bot-token-here
533
-
534
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
535
- Step 3: Start your tenant
536
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
537
-
538
- docker compose -f ${tenantPath}/compose.yml up -d
539
-
540
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
541
-
542
- Useful commands:
543
- otto tenant status ${tenantPathArg} — check health
544
- otto tenant logs ${tenantPathArg} — view logs
545
- otto tenant down ${tenantPathArg} — stop tenant
546
- otto tenant skills bootstrap ${tenantPathArg} — install baseline skills
547
- `);
548
- return;
549
1632
  }
550
- case "up": {
551
- const mode = resolveTenantMode(process.env.OTTO_MODE);
552
- if (mode === "admin") {
553
- console.log("⚠ OTTO_MODE=admin enabled: tenant has elevated runtime profile.");
1633
+ await new Promise((resolve) => {
1634
+ setTimeout(resolve, RESTART_LOG_POLL_MS);
1635
+ });
1636
+ }
1637
+ }
1638
+ function trimPromptValue(value) {
1639
+ if (typeof value !== 'string') {
1640
+ return '';
1641
+ }
1642
+ return value.trim();
1643
+ }
1644
+ async function promptRequiredText({ message, placeholder, }) {
1645
+ const value = await text({
1646
+ message,
1647
+ ...(placeholder ? { placeholder } : {}),
1648
+ validate: (raw) => {
1649
+ if (!trimPromptValue(raw)) {
1650
+ return 'Value is required';
554
1651
  }
555
- runCompose(tenantPath, ["up", "-d"]);
1652
+ },
1653
+ });
1654
+ if (isCancel(value)) {
1655
+ return new Error('Cancelled');
1656
+ }
1657
+ return trimPromptValue(value);
1658
+ }
1659
+ async function promptOptionalText({ message, placeholder, }) {
1660
+ const value = await text({
1661
+ message,
1662
+ ...(placeholder ? { placeholder } : {}),
1663
+ });
1664
+ if (isCancel(value)) {
1665
+ return new Error('Cancelled');
1666
+ }
1667
+ const trimmed = trimPromptValue(value);
1668
+ return trimmed || null;
1669
+ }
1670
+ async function runInteractiveMenu() {
1671
+ const categories = [
1672
+ {
1673
+ label: 'Bot Management',
1674
+ actions: [
1675
+ {
1676
+ label: 'Start',
1677
+ run: async () => runMenuSubcommand({ args: ['gateway', 'start'] }),
1678
+ },
1679
+ {
1680
+ label: 'Restart',
1681
+ run: async () => runMenuSubcommand({ args: ['gateway', 'restart'] }),
1682
+ },
1683
+ {
1684
+ label: 'Status Set',
1685
+ run: async () => {
1686
+ const textValue = await promptRequiredText({ message: 'Status text' });
1687
+ if (textValue instanceof Error)
1688
+ return textValue;
1689
+ const type = await select({
1690
+ message: 'Activity type',
1691
+ options: [
1692
+ { label: 'Custom', value: 'custom' },
1693
+ { label: 'Playing', value: 'playing' },
1694
+ { label: 'Watching', value: 'watching' },
1695
+ { label: 'Listening', value: 'listening' },
1696
+ { label: 'Competing', value: 'competing' },
1697
+ ],
1698
+ });
1699
+ if (isCancel(type))
1700
+ return new Error('Cancelled');
1701
+ const status = await select({
1702
+ message: 'Online status',
1703
+ options: [
1704
+ { label: 'Online', value: 'online' },
1705
+ { label: 'Idle', value: 'idle' },
1706
+ { label: 'DnD', value: 'dnd' },
1707
+ { label: 'Invisible', value: 'invisible' },
1708
+ ],
1709
+ });
1710
+ if (isCancel(status))
1711
+ return new Error('Cancelled');
1712
+ return runMenuSubcommand({ args: ['bot', 'status', 'set', textValue, '--type', String(type), '--status', String(status)] });
1713
+ },
1714
+ },
1715
+ { label: 'Status Clear', run: async () => runMenuSubcommand({ args: ['bot', 'status', 'clear'] }) },
1716
+ { label: 'Install URL', run: async () => runMenuSubcommand({ args: ['bot', 'install-url'] }) },
1717
+ ],
1718
+ },
1719
+ {
1720
+ label: 'Session Management',
1721
+ actions: [
1722
+ { label: 'List', run: async () => runMenuSubcommand({ args: ['session', 'list'] }) },
1723
+ {
1724
+ label: 'Read',
1725
+ run: async () => {
1726
+ const id = await promptRequiredText({ message: 'Session ID' });
1727
+ if (id instanceof Error)
1728
+ return id;
1729
+ return runMenuSubcommand({ args: ['session', 'read', id] });
1730
+ },
1731
+ },
1732
+ {
1733
+ label: 'Search',
1734
+ run: async () => {
1735
+ const query = await promptRequiredText({ message: 'Search query' });
1736
+ if (query instanceof Error)
1737
+ return query;
1738
+ return runMenuSubcommand({ args: ['session', 'search', query] });
1739
+ },
1740
+ },
1741
+ {
1742
+ label: 'Archive',
1743
+ run: async () => {
1744
+ const sessionId = await promptOptionalText({ message: 'Session ID (optional)' });
1745
+ if (sessionId instanceof Error)
1746
+ return sessionId;
1747
+ if (sessionId)
1748
+ return runMenuSubcommand({ args: ['session', 'archive', '--session', sessionId] });
1749
+ const threadId = await promptRequiredText({ message: 'Thread ID' });
1750
+ if (threadId instanceof Error)
1751
+ return threadId;
1752
+ return runMenuSubcommand({ args: ['session', 'archive', threadId] });
1753
+ },
1754
+ },
1755
+ {
1756
+ label: 'Discord URL',
1757
+ run: async () => {
1758
+ const id = await promptRequiredText({ message: 'Session ID' });
1759
+ if (id instanceof Error)
1760
+ return id;
1761
+ return runMenuSubcommand({ args: ['session', 'discord-url', id] });
1762
+ },
1763
+ },
1764
+ {
1765
+ label: 'Export Events',
1766
+ run: async () => {
1767
+ const id = await promptRequiredText({ message: 'Session ID' });
1768
+ if (id instanceof Error)
1769
+ return id;
1770
+ const outPath = await promptRequiredText({ message: 'Output file (.jsonl)', placeholder: './tmp/session-events.jsonl' });
1771
+ if (outPath instanceof Error)
1772
+ return outPath;
1773
+ return runMenuSubcommand({ args: ['session', 'export-events-jsonl', '--session', id, '--out', outPath] });
1774
+ },
1775
+ },
1776
+ ],
1777
+ },
1778
+ {
1779
+ label: 'Project Management',
1780
+ actions: [
1781
+ { label: 'Add', run: async () => runMenuSubcommand({ args: ['project', 'add'] }) },
1782
+ { label: 'List', run: async () => runMenuSubcommand({ args: ['project', 'list'] }) },
1783
+ { label: 'Open In Discord', run: async () => runMenuSubcommand({ args: ['project', 'open-in-discord'] }) },
1784
+ {
1785
+ label: 'Create',
1786
+ run: async () => {
1787
+ const name = await promptRequiredText({ message: 'Project name', placeholder: 'my-project' });
1788
+ if (name instanceof Error)
1789
+ return name;
1790
+ return runMenuSubcommand({ args: ['project', 'create', name] });
1791
+ },
1792
+ },
1793
+ {
1794
+ label: 'Remove (not available)',
1795
+ run: async () => {
1796
+ note('No dedicated `project remove` command is currently available.', 'Info');
1797
+ },
1798
+ },
1799
+ ],
1800
+ },
1801
+ {
1802
+ label: 'Task Management',
1803
+ actions: [
1804
+ { label: 'List Tasks', run: async () => runMenuSubcommand({ args: ['task', 'list'] }) },
1805
+ {
1806
+ label: 'Schedule Send',
1807
+ run: async () => {
1808
+ const project = await promptRequiredText({ message: 'Project path', placeholder: '.' });
1809
+ if (project instanceof Error)
1810
+ return project;
1811
+ const promptValue = await promptRequiredText({ message: 'Prompt' });
1812
+ if (promptValue instanceof Error)
1813
+ return promptValue;
1814
+ const sendAt = await promptRequiredText({ message: 'Send at (UTC ISO or cron)' });
1815
+ if (sendAt instanceof Error)
1816
+ return sendAt;
1817
+ return runMenuSubcommand({ args: ['send', '--project', project, '--prompt', promptValue, '--send-at', sendAt] });
1818
+ },
1819
+ },
1820
+ {
1821
+ label: 'Edit Task',
1822
+ run: async () => {
1823
+ const id = await promptRequiredText({ message: 'Task ID' });
1824
+ if (id instanceof Error)
1825
+ return id;
1826
+ const args = ['task', 'edit', id];
1827
+ const promptValue = await promptOptionalText({ message: 'New prompt (optional)' });
1828
+ if (promptValue instanceof Error)
1829
+ return promptValue;
1830
+ if (promptValue)
1831
+ args.push('--prompt', promptValue);
1832
+ const sendAt = await promptOptionalText({ message: 'New send-at (optional)' });
1833
+ if (sendAt instanceof Error)
1834
+ return sendAt;
1835
+ if (sendAt)
1836
+ args.push('--send-at', sendAt);
1837
+ return runMenuSubcommand({ args });
1838
+ },
1839
+ },
1840
+ {
1841
+ label: 'Delete Task',
1842
+ run: async () => {
1843
+ const id = await promptRequiredText({ message: 'Task ID' });
1844
+ if (id instanceof Error)
1845
+ return id;
1846
+ return runMenuSubcommand({ args: ['task', 'delete', id] });
1847
+ },
1848
+ },
1849
+ ],
1850
+ },
1851
+ {
1852
+ label: 'Worktree Management',
1853
+ actions: [
1854
+ {
1855
+ label: 'New Worktree',
1856
+ run: async () => {
1857
+ const project = await promptRequiredText({ message: 'Project path', placeholder: '.' });
1858
+ if (project instanceof Error)
1859
+ return project;
1860
+ const promptValue = await promptRequiredText({ message: 'Initial prompt' });
1861
+ if (promptValue instanceof Error)
1862
+ return promptValue;
1863
+ const name = await promptOptionalText({ message: 'Worktree name (optional)' });
1864
+ if (name instanceof Error)
1865
+ return name;
1866
+ const args = ['send', '--project', project, '--prompt', promptValue, '--worktree'];
1867
+ if (name)
1868
+ args.push(name);
1869
+ return runMenuSubcommand({ args });
1870
+ },
1871
+ },
1872
+ { label: 'Merge Worktree', run: async () => runMenuSubcommand({ args: ['worktree', 'merge'] }) },
1873
+ {
1874
+ label: 'List Worktrees',
1875
+ run: async () => {
1876
+ const result = await execAsync('git worktree list', { timeout: 15_000 }).catch((cause) => new Error('Failed to list worktrees', { cause }));
1877
+ if (result instanceof Error)
1878
+ return result;
1879
+ note(result.stdout.trim() || '(no worktrees)', 'Worktrees');
1880
+ },
1881
+ },
1882
+ ],
1883
+ },
1884
+ {
1885
+ label: 'Account Management',
1886
+ actions: [
1887
+ { label: 'Anthropic List', run: async () => runMenuSubcommand({ args: ['anthropic-accounts', 'list'] }) },
1888
+ { label: 'Anthropic Current', run: async () => runMenuSubcommand({ args: ['anthropic-accounts', 'current'] }) },
1889
+ {
1890
+ label: 'Anthropic Remove',
1891
+ run: async () => {
1892
+ const value = await promptRequiredText({ message: 'Index or email' });
1893
+ if (value instanceof Error)
1894
+ return value;
1895
+ return runMenuSubcommand({ args: ['anthropic-accounts', 'remove', value] });
1896
+ },
1897
+ },
1898
+ {
1899
+ label: 'Login',
1900
+ run: async () => runMenuSubcommand({
1901
+ args: ['gateway', 'start', '--restart-onboarding'],
1902
+ }),
1903
+ },
1904
+ ],
1905
+ },
1906
+ {
1907
+ label: 'Tools',
1908
+ actions: [
1909
+ {
1910
+ label: 'Tunnel',
1911
+ run: async () => {
1912
+ const port = await promptRequiredText({ message: 'Port', placeholder: '3000' });
1913
+ if (port instanceof Error)
1914
+ return port;
1915
+ return runMenuSubcommand({ args: ['tunnel', '--port', port] });
1916
+ },
1917
+ },
1918
+ { label: 'Screenshare', run: async () => runMenuSubcommand({ args: ['screenshare'] }) },
1919
+ { label: 'Sqlitedb', run: async () => runMenuSubcommand({ args: ['sqlitedb'] }) },
1920
+ { label: 'Upgrade', run: async () => runMenuSubcommand({ args: ['upgrade'] }) },
1921
+ ],
1922
+ },
1923
+ {
1924
+ label: 'User Management',
1925
+ actions: [
1926
+ {
1927
+ label: 'User List',
1928
+ run: async () => {
1929
+ const guild = await promptRequiredText({ message: 'Guild ID' });
1930
+ if (guild instanceof Error)
1931
+ return guild;
1932
+ const query = await promptOptionalText({ message: 'Search query (optional)' });
1933
+ if (query instanceof Error)
1934
+ return query;
1935
+ const args = ['user', 'list', '--guild', guild];
1936
+ if (query)
1937
+ args.push('--query', query);
1938
+ return runMenuSubcommand({ args });
1939
+ },
1940
+ },
1941
+ ],
1942
+ },
1943
+ ];
1944
+ while (true) {
1945
+ const category = await select({
1946
+ message: 'Otto main menu',
1947
+ options: [
1948
+ ...categories.map((x) => ({ label: x.label, value: x.label })),
1949
+ { label: 'Quit', value: '__quit__' },
1950
+ ],
1951
+ });
1952
+ if (isCancel(category) || category === '__quit__') {
1953
+ outro('Goodbye.');
556
1954
  return;
557
1955
  }
558
- case "down":
559
- runCompose(tenantPath, ["down"]);
560
- return;
561
- case "logs": {
562
- const follow = subArgs.includes("--follow") ? ["--follow"] : [];
563
- runCompose(tenantPath, ["logs", ...follow]);
564
- return;
1956
+ const selectedCategory = categories.find((x) => x.label === category);
1957
+ if (!selectedCategory) {
1958
+ continue;
1959
+ }
1960
+ const action = await select({
1961
+ message: `${selectedCategory.label} actions`,
1962
+ options: [
1963
+ ...selectedCategory.actions.map((x) => ({ label: x.label, value: x.label })),
1964
+ { label: 'Back', value: '__back__' },
1965
+ ],
1966
+ });
1967
+ if (isCancel(action) || action === '__back__') {
1968
+ continue;
1969
+ }
1970
+ const selectedAction = selectedCategory.actions.find((x) => x.label === action);
1971
+ if (!selectedAction) {
1972
+ continue;
1973
+ }
1974
+ const result = await selectedAction.run();
1975
+ if (result instanceof Error && result.message !== 'Cancelled') {
1976
+ note(result.message, 'Command Failed');
1977
+ continue;
1978
+ }
1979
+ note('Command completed. Returning to menu.', 'Done');
1980
+ }
1981
+ }
1982
+ cli
1983
+ .command('menu', 'Interactive command menu for all otto commands')
1984
+ .action(async () => {
1985
+ try {
1986
+ intro('Otto interactive menu');
1987
+ await runInteractiveMenu();
1988
+ process.exit(0);
1989
+ }
1990
+ catch (error) {
1991
+ cliLogger.error('Menu failed:', formatErrorWithStack(error));
1992
+ process.exit(EXIT_NO_RESTART);
1993
+ }
1994
+ });
1995
+ cli
1996
+ .command('', 'Open the interactive Otto menu')
1997
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.otto)')
1998
+ .option('--projects-dir <path>', 'Directory where new projects are created (default: <data-dir>/projects)')
1999
+ .action(async (options) => {
2000
+ try {
2001
+ if (options.dataDir) {
2002
+ setDataDir(options.dataDir);
2003
+ cliLogger.log(`Using data directory: ${getDataDir()}`);
2004
+ }
2005
+ if (options.projectsDir) {
2006
+ setProjectsDir(options.projectsDir);
2007
+ cliLogger.log(`Using projects directory: ${getProjectsDir()}`);
2008
+ }
2009
+ intro('Otto interactive menu');
2010
+ await runInteractiveMenu();
2011
+ process.exit(0);
2012
+ }
2013
+ catch (error) {
2014
+ cliLogger.error('Menu failed:', formatErrorWithStack(error));
2015
+ process.exit(EXIT_NO_RESTART);
2016
+ }
2017
+ });
2018
+ cli
2019
+ .command('gateway start', 'Connect Otto to Discord and keep the long-running gateway session active')
2020
+ .option('--restart-onboarding', 'Prompt for new credentials even if saved')
2021
+ .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
2022
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.otto)')
2023
+ .option('--projects-dir <path>', 'Directory where new projects are created (default: <data-dir>/projects)')
2024
+ .option('--install-url', 'Print the bot install URL and exit')
2025
+ .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
2026
+ .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
2027
+ .option('--verbosity <level>', 'Default verbosity for all channels (tools_and_text, text_and_essential_tools, or text_only)')
2028
+ .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
2029
+ .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
2030
+ .option('--auto-restart', 'Reserved for compatibility; crash recovery is handled by the supervisor around `otto gateway start`')
2031
+ .option('--no-sentry', 'Disable Sentry error reporting')
2032
+ .option('--gateway', 'Force gateway mode (use the gateway Otto bot instead of a self-hosted bot)')
2033
+ .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
2034
+ .option('--gateway-platform <platform>', 'Gateway platform to use (discord or telegram). Default: discord')
2035
+ .option('--telegram-bot-token <token>', 'Telegram BotFather token used for telegram gateway onboarding')
2036
+ .option('--telegram-chat-id <id>', 'Telegram supergroup chat ID used for telegram gateway onboarding')
2037
+ .option('--enable-skill <name>', z
2038
+ .array(z.string())
2039
+ .optional()
2040
+ .describe('Whitelist a built-in skill by name. Only the listed skills are injected into the model (all others are hidden via an opencode permission.skill deny-all rule). Repeatable: pass --enable-skill multiple times. Mutually exclusive with --disable-skill. See https://github.com/otto-assistant/bridge/tree/main/skills for available skills.'))
2041
+ .option('--disable-skill <name>', z
2042
+ .array(z.string())
2043
+ .optional()
2044
+ .describe('Blacklist a built-in skill by name. Listed skills are hidden from the model. Repeatable: pass --disable-skill multiple times. Mutually exclusive with --enable-skill. See https://github.com/otto-assistant/bridge/tree/main/skills for available skills.'))
2045
+ .action(async (options) => {
2046
+ if (process.env.OTTO_OPENCODE_PROCESS) {
2047
+ cliLogger.log('Detected OpenCode session. Launching OpenCode TUI instead of restarting the otto bot process.');
2048
+ const exitCode = await new Promise((resolve, reject) => {
2049
+ const tuiProcess = spawn('opencode', [], {
2050
+ stdio: 'inherit',
2051
+ env: process.env,
2052
+ });
2053
+ tuiProcess.once('error', (error) => {
2054
+ reject(error);
2055
+ });
2056
+ tuiProcess.once('exit', (code, signal) => {
2057
+ if (signal) {
2058
+ reject(new Error(`OpenCode TUI exited due to signal: ${signal}`));
2059
+ return;
2060
+ }
2061
+ resolve(code ?? 0);
2062
+ });
2063
+ }).catch((error) => {
2064
+ cliLogger.error('Failed to launch OpenCode TUI:', formatErrorWithStack(error));
2065
+ return EXIT_NO_RESTART;
2066
+ });
2067
+ process.exit(exitCode);
2068
+ }
2069
+ try {
2070
+ if (options.dataDir) {
2071
+ setDataDir(options.dataDir);
2072
+ cliLogger.log(`Using data directory: ${getDataDir()}`);
2073
+ }
2074
+ if (options.projectsDir) {
2075
+ setProjectsDir(options.projectsDir);
2076
+ cliLogger.log(`Using projects directory: ${getProjectsDir()}`);
565
2077
  }
566
- case "status": {
567
- const health = checkTenantHealth({ tenantPath });
568
- console.log(`Tenant status: ${tenantPath}`);
569
- for (const item of health) {
570
- const icon = item.status === "ok" ? "✓" : item.status === "warn" ? "⚠" : "✗";
571
- console.log(` ${icon} ${item.name}: ${item.message}`);
2078
+ initLogFile(getDataDir());
2079
+ if (options.verbosity) {
2080
+ const validLevels = [
2081
+ 'tools_and_text',
2082
+ 'text_and_essential_tools',
2083
+ 'text_only',
2084
+ ];
2085
+ if (!validLevels.includes(options.verbosity)) {
2086
+ cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use one of: ${validLevels.join(', ')}`);
2087
+ process.exit(EXIT_NO_RESTART);
572
2088
  }
573
- const composeExists = fs.existsSync(path.join(tenantPath, "compose.yml"));
574
- const skipComposePs = process.env.OTTO_SKIP_COMPOSE_PS === "1";
575
- if (composeExists && !skipComposePs) {
2089
+ }
2090
+ const enabledSkills = options.enableSkill ?? [];
2091
+ const disabledSkills = options.disableSkill ?? [];
2092
+ if (enabledSkills.length > 0 && disabledSkills.length > 0) {
2093
+ cliLogger.error('Cannot use --enable-skill and --disable-skill at the same time. Use one or the other.');
2094
+ process.exit(EXIT_NO_RESTART);
2095
+ }
2096
+ if (enabledSkills.length > 0 || disabledSkills.length > 0) {
2097
+ const bundledSkillsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills');
2098
+ const availableBundledSkills = (() => {
576
2099
  try {
577
- runCompose(tenantPath, ["ps"]);
2100
+ return fs
2101
+ .readdirSync(bundledSkillsDir, { withFileTypes: true })
2102
+ .filter((entry) => entry.isDirectory())
2103
+ .map((entry) => entry.name);
578
2104
  }
579
2105
  catch {
580
- console.log(" ⚠ docker compose ps failed");
2106
+ return [];
2107
+ }
2108
+ })();
2109
+ const availableSet = new Set(availableBundledSkills);
2110
+ for (const name of [...enabledSkills, ...disabledSkills]) {
2111
+ if (!availableSet.has(name)) {
2112
+ cliLogger.warn(`Skill "${name}" is not a bundled otto skill. Rule will still apply (user-provided skills from .opencode/.claude/.agents dirs may match). Available bundled skills: ${availableBundledSkills.join(', ')}`);
581
2113
  }
582
2114
  }
583
- return;
584
2115
  }
585
- default:
586
- console.log("Usage: otto tenant <init|up|down|status|logs> <path> | otto tenant skills bootstrap <path>");
587
- process.exit(1);
2116
+ store.setState({
2117
+ ...(options.verbosity && {
2118
+ defaultVerbosity: options.verbosity,
2119
+ }),
2120
+ ...(options.mentionMode && { defaultMentionMode: true }),
2121
+ ...(options.noCritique && { critiqueEnabled: false }),
2122
+ ...(enabledSkills.length > 0 && { enabledSkills }),
2123
+ ...(disabledSkills.length > 0 && { disabledSkills }),
2124
+ });
2125
+ if (enabledSkills.length > 0) {
2126
+ cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
2127
+ }
2128
+ if (disabledSkills.length > 0) {
2129
+ cliLogger.log(`Skill blacklist enabled: [${disabledSkills.join(', ')}] will be hidden`);
2130
+ }
2131
+ if (options.verbosity) {
2132
+ cliLogger.log(`Default verbosity: ${options.verbosity}`);
2133
+ }
2134
+ if (options.mentionMode) {
2135
+ cliLogger.log('Default mention mode: enabled (bot only responds when @mentioned)');
2136
+ }
2137
+ if (options.noCritique) {
2138
+ cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
2139
+ }
2140
+ if (options.noSentry) {
2141
+ process.env.OTTO_SENTRY_DISABLED = '1';
2142
+ cliLogger.log('Sentry error reporting disabled (--no-sentry)');
2143
+ }
2144
+ else {
2145
+ initSentry();
2146
+ }
2147
+ if (options.installUrl) {
2148
+ await printDiscordInstallUrlAndExit({
2149
+ gateway: options.gateway,
2150
+ gatewayPlatform: parseGatewayPlatform(options.gatewayPlatform),
2151
+ gatewayCallbackUrl: options.gatewayCallbackUrl,
2152
+ });
2153
+ }
2154
+ await run({
2155
+ restartOnboarding: options.restartOnboarding,
2156
+ addChannels: options.addChannels,
2157
+ dataDir: options.dataDir,
2158
+ useWorktrees: options.useWorktrees,
2159
+ enableVoiceChannels: options.enableVoiceChannels,
2160
+ gateway: options.gateway,
2161
+ gatewayPlatform: parseGatewayPlatform(options.gatewayPlatform),
2162
+ gatewayCallbackUrl: options.gatewayCallbackUrl,
2163
+ telegramBotToken: options.telegramBotToken,
2164
+ telegramChatId: options.telegramChatId,
2165
+ });
588
2166
  }
589
- }
590
- // ---------------------------------------------------------------------------
591
- // Main router
592
- // ---------------------------------------------------------------------------
593
- async function main() {
594
- switch (command) {
595
- case "install":
596
- await cmdInstall();
597
- break;
598
- case "upgrade":
599
- await cmdUpgrade(subCommand === "latest" ? "latest" : "stable");
600
- break;
601
- case "status":
602
- await cmdStatus();
603
- break;
604
- case "doctor":
605
- await cmdDoctor();
606
- break;
607
- case "sync":
608
- await syncUpstreams();
609
- break;
610
- case "skills":
611
- await cmdSkills(args.slice(1));
612
- break;
613
- case "tenant":
614
- await cmdTenant(args.slice(1));
615
- break;
616
- default:
617
- console.log(`Otto — terminal UI distribution for opencode + kimaki + mempalace
618
-
619
- Usage:
620
- otto tenant init <path> Create compose-first tenant scaffold
621
- otto tenant up <path> Start tenant with docker compose
622
- otto tenant down <path> Stop tenant with docker compose
623
- otto tenant status <path> Show tenant preflight + compose status
624
- otto tenant logs <path> Show tenant logs (add --follow)
625
- otto tenant skills bootstrap <path> Install baseline skills for tenant
626
-
627
- otto install Legacy: install missing npm packages + configure
628
- otto upgrade Legacy: upgrade to stable (manifest-pinned) versions
629
- otto upgrade stable Legacy: upgrade to manifest-pinned versions
630
- otto upgrade latest Legacy: upgrade to npm latest versions
631
- otto status Show installed versions + config health
632
- otto doctor Validate all integration points
633
- otto sync Trigger upstream sync for all forked repos
634
- otto skills search <q> Search skills across public repos
635
- otto skills browse Browse all available skills
636
- otto skills list List installed skills
637
- otto skills add <name> Install a skill
638
- otto skills add --all Install all skills from otto-assistant/skills
639
- otto skills update Refresh skills index
640
- otto skills remove <name> Remove an installed skill
641
- otto skills repos Show configured skill repositories
642
- `);
643
- break;
2167
+ catch (error) {
2168
+ cliLogger.error('Unhandled error:', formatErrorWithStack(error));
2169
+ process.exit(EXIT_NO_RESTART);
2170
+ }
2171
+ });
2172
+ cli
2173
+ .command('gateway restart', 'Spawn a fresh `gateway start` process; replaces the existing instance via the single-instance lock')
2174
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.otto)')
2175
+ .option('--projects-dir <path>', 'Directory where new projects are created (default: <data-dir>/projects)')
2176
+ .action(async (options) => {
2177
+ try {
2178
+ const spawnArgs = ['gateway', 'start'];
2179
+ if (options.dataDir) {
2180
+ spawnArgs.push('--data-dir', options.dataDir);
2181
+ }
2182
+ if (options.projectsDir) {
2183
+ spawnArgs.push('--projects-dir', options.projectsDir);
2184
+ }
2185
+ const detachedGateway = spawnDetachedGatewayStart({ extraArgs: spawnArgs.slice(2) });
2186
+ if (detachedGateway instanceof Error) {
2187
+ throw detachedGateway;
2188
+ }
2189
+ cliLogger.log('Restarting Otto gateway...');
2190
+ await printGatewayStartupLogs({
2191
+ dataDir: resolveGatewayDataDir({
2192
+ dataDir: options.dataDir,
2193
+ }),
2194
+ });
2195
+ process.exit(0);
2196
+ }
2197
+ catch (error) {
2198
+ cliLogger.error('Gateway restart failed:', error instanceof Error ? error.stack : String(error));
2199
+ process.exit(EXIT_NO_RESTART);
2200
+ }
2201
+ });
2202
+ cli
2203
+ .command('discord-install-url', 'Print the bot install URL and exit')
2204
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.otto)')
2205
+ .option('--gateway', 'Print the gateway install URL and create local gateway credentials if missing')
2206
+ .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
2207
+ .option('--gateway-platform <platform>', 'Gateway platform to use for install URL (discord or telegram). Default: discord')
2208
+ .action(async (options) => {
2209
+ try {
2210
+ if (options.dataDir) {
2211
+ setDataDir(options.dataDir);
2212
+ cliLogger.log(`Using data directory: ${getDataDir()}`);
2213
+ }
2214
+ initLogFile(getDataDir());
2215
+ await printDiscordInstallUrlAndExit({
2216
+ gateway: options.gateway,
2217
+ gatewayPlatform: parseGatewayPlatform(options.gatewayPlatform),
2218
+ gatewayCallbackUrl: options.gatewayCallbackUrl,
2219
+ });
2220
+ }
2221
+ catch (error) {
2222
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2223
+ process.exit(EXIT_NO_RESTART);
2224
+ }
2225
+ });
2226
+ // ── bot command group ────────────────────────────────────────────────────
2227
+ const ACTIVITY_TYPE_MAP = {
2228
+ playing: ActivityType.Playing,
2229
+ watching: ActivityType.Watching,
2230
+ listening: ActivityType.Listening,
2231
+ competing: ActivityType.Competing,
2232
+ custom: ActivityType.Custom,
2233
+ };
2234
+ const STATUS_MAP = {
2235
+ online: 'online',
2236
+ idle: 'idle',
2237
+ dnd: 'dnd',
2238
+ invisible: 'invisible',
2239
+ };
2240
+ cli
2241
+ .command('bot install-url', 'Print the bot install URL')
2242
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.otto)')
2243
+ .option('--gateway', 'Print the gateway install URL and create local gateway credentials if missing')
2244
+ .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
2245
+ .option('--gateway-platform <platform>', 'Gateway platform to use for install URL (discord or telegram). Default: discord')
2246
+ .action(async (options) => {
2247
+ try {
2248
+ if (options.dataDir) {
2249
+ setDataDir(options.dataDir);
2250
+ cliLogger.log(`Using data directory: ${getDataDir()}`);
2251
+ }
2252
+ initLogFile(getDataDir());
2253
+ await printDiscordInstallUrlAndExit({
2254
+ gateway: options.gateway,
2255
+ gatewayPlatform: parseGatewayPlatform(options.gatewayPlatform),
2256
+ gatewayCallbackUrl: options.gatewayCallbackUrl,
2257
+ });
2258
+ }
2259
+ catch (error) {
2260
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2261
+ process.exit(EXIT_NO_RESTART);
2262
+ }
2263
+ });
2264
+ // Max length for activity name/state — Discord silently truncates beyond 128 chars.
2265
+ const MAX_STATUS_TEXT_LENGTH = 128;
2266
+ // Login timeout for temporary discord.js clients (10s).
2267
+ const BOT_LOGIN_TIMEOUT_MS = 10_000;
2268
+ // Wait for gateway opcode 3 websocket frame to flush before destroying the client.
2269
+ const PRESENCE_FLUSH_DELAY_MS = 1200;
2270
+ /**
2271
+ * Create a temporary discord.js client, connect to gateway, run a callback,
2272
+ * then tear down. Includes a login timeout so the command doesn't hang forever.
2273
+ */
2274
+ async function withTempDiscordClient({ token, onReady, }) {
2275
+ const client = await createDiscordClient();
2276
+ try {
2277
+ await Promise.race([
2278
+ new Promise((resolve, reject) => {
2279
+ client.once(Events.ClientReady, () => {
2280
+ resolve();
2281
+ });
2282
+ client.once(Events.Error, reject);
2283
+ client.login(token).catch(reject);
2284
+ }),
2285
+ new Promise((_, reject) => {
2286
+ setTimeout(() => {
2287
+ reject(new Error('Discord login timed out (10s)'));
2288
+ }, BOT_LOGIN_TIMEOUT_MS);
2289
+ }),
2290
+ ]);
2291
+ if (!client.isReady() || !client.user) {
2292
+ throw new Error('Discord client ready but user is missing');
2293
+ }
2294
+ await onReady(client);
2295
+ }
2296
+ finally {
2297
+ client.destroy();
2298
+ }
2299
+ }
2300
+ cli
2301
+ .command('bot status set <text>', 'Set the bot presence/status in Discord')
2302
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.otto)')
2303
+ .option('--type <activityType>', 'Activity type: playing, watching, listening, competing, custom (default: custom)')
2304
+ .option('--status <onlineStatus>', 'Online status: online, idle, dnd, invisible (default: online)')
2305
+ .action(async (text, options) => {
2306
+ try {
2307
+ if (options.dataDir) {
2308
+ setDataDir(options.dataDir);
2309
+ }
2310
+ initLogFile(getDataDir());
2311
+ await initDatabase();
2312
+ const botRow = await getBotTokenWithMode();
2313
+ if (!botRow) {
2314
+ cliLogger.error('No bot configured. Run `otto gateway start` first.');
2315
+ process.exit(EXIT_NO_RESTART);
2316
+ }
2317
+ if (botRow.mode === 'gateway') {
2318
+ cliLogger.error('Cannot set status in gateway mode — it would change the shared bot status for all users.');
2319
+ process.exit(EXIT_NO_RESTART);
2320
+ }
2321
+ if (text.length > MAX_STATUS_TEXT_LENGTH) {
2322
+ cliLogger.error(`Status text too long (${text.length} chars, max ${MAX_STATUS_TEXT_LENGTH}).`);
2323
+ process.exit(EXIT_NO_RESTART);
2324
+ }
2325
+ const activityTypeKey = (options.type || 'custom').toLowerCase();
2326
+ const activityType = ACTIVITY_TYPE_MAP[activityTypeKey];
2327
+ if (activityType === undefined) {
2328
+ cliLogger.error(`Unknown activity type: ${options.type}. Use: playing, watching, listening, competing, custom`);
2329
+ process.exit(EXIT_NO_RESTART);
2330
+ }
2331
+ const statusKey = (options.status || 'online').toLowerCase();
2332
+ const onlineStatus = STATUS_MAP[statusKey];
2333
+ if (!onlineStatus) {
2334
+ cliLogger.error(`Unknown status: ${options.status}. Use: online, idle, dnd, invisible`);
2335
+ process.exit(EXIT_NO_RESTART);
2336
+ }
2337
+ cliLogger.log('Connecting to Discord...');
2338
+ await withTempDiscordClient({
2339
+ token: botRow.token,
2340
+ onReady: async (client) => {
2341
+ // For custom activity type, use state field (shows as the status text).
2342
+ // For other types, use name field (shows as "Playing X", "Watching X", etc).
2343
+ const activity = activityType === ActivityType.Custom
2344
+ ? { name: 'Custom Status', type: activityType, state: text }
2345
+ : { name: text, type: activityType };
2346
+ client.user.setPresence({
2347
+ activities: [activity],
2348
+ status: onlineStatus,
2349
+ });
2350
+ // setPresence queues a gateway opcode 3 over websocket.
2351
+ // Wait so the frame flushes before we tear down the connection.
2352
+ await new Promise((resolve) => {
2353
+ setTimeout(resolve, PRESENCE_FLUSH_DELAY_MS);
2354
+ });
2355
+ cliLogger.log(`Status set: ${activityTypeKey === 'custom' ? text : `${activityTypeKey} ${text}`} (${statusKey})`);
2356
+ },
2357
+ });
2358
+ process.exit(0);
2359
+ }
2360
+ catch (error) {
2361
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2362
+ process.exit(EXIT_NO_RESTART);
2363
+ }
2364
+ });
2365
+ cli
2366
+ .command('bot status clear', 'Clear the bot presence/status')
2367
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.otto)')
2368
+ .action(async (options) => {
2369
+ try {
2370
+ if (options.dataDir) {
2371
+ setDataDir(options.dataDir);
2372
+ }
2373
+ initLogFile(getDataDir());
2374
+ await initDatabase();
2375
+ const botRow = await getBotTokenWithMode();
2376
+ if (!botRow) {
2377
+ cliLogger.error('No bot configured. Run `otto gateway start` first.');
2378
+ process.exit(EXIT_NO_RESTART);
2379
+ }
2380
+ if (botRow.mode === 'gateway') {
2381
+ cliLogger.error('Cannot clear status in gateway mode — it would change the shared bot status for all users.');
2382
+ process.exit(EXIT_NO_RESTART);
2383
+ }
2384
+ cliLogger.log('Connecting to Discord...');
2385
+ await withTempDiscordClient({
2386
+ token: botRow.token,
2387
+ onReady: async (client) => {
2388
+ client.user.setPresence({
2389
+ activities: [],
2390
+ status: 'online',
2391
+ });
2392
+ await new Promise((resolve) => {
2393
+ setTimeout(resolve, PRESENCE_FLUSH_DELAY_MS);
2394
+ });
2395
+ cliLogger.log('Status cleared');
2396
+ },
2397
+ });
2398
+ process.exit(0);
2399
+ }
2400
+ catch (error) {
2401
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2402
+ process.exit(EXIT_NO_RESTART);
2403
+ }
2404
+ });
2405
+ cli
2406
+ .command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
2407
+ .option('-s, --session <sessionId>', 'OpenCode session ID')
2408
+ .action(async (files, options) => {
2409
+ try {
2410
+ const { session: sessionId } = options;
2411
+ if (!sessionId) {
2412
+ cliLogger.error('Session ID is required. Use --session <sessionId>');
2413
+ process.exit(EXIT_NO_RESTART);
2414
+ }
2415
+ if (!files || files.length === 0) {
2416
+ cliLogger.error('At least one file path is required');
2417
+ process.exit(EXIT_NO_RESTART);
2418
+ }
2419
+ const resolvedFiles = files.map((f) => path.resolve(f));
2420
+ for (const file of resolvedFiles) {
2421
+ if (!fs.existsSync(file)) {
2422
+ cliLogger.error(`File not found: ${file}`);
2423
+ process.exit(EXIT_NO_RESTART);
2424
+ }
2425
+ }
2426
+ await initDatabase();
2427
+ const threadId = await getThreadIdBySessionId(sessionId);
2428
+ if (!threadId) {
2429
+ cliLogger.error(`No Discord thread found for session: ${sessionId}`);
2430
+ process.exit(EXIT_NO_RESTART);
2431
+ }
2432
+ const botRow = await getBotTokenWithMode();
2433
+ if (!botRow) {
2434
+ cliLogger.error('No bot credentials found. Run `otto gateway start` first to set up the bot.');
2435
+ process.exit(EXIT_NO_RESTART);
2436
+ }
2437
+ cliLogger.log(`Uploading ${resolvedFiles.length} file(s)...`);
2438
+ await uploadFilesToDiscord({
2439
+ threadId: threadId,
2440
+ botToken: botRow.token,
2441
+ files: resolvedFiles,
2442
+ });
2443
+ cliLogger.log(`Uploaded ${resolvedFiles.length} file(s)!`);
2444
+ note(`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`, '✅ Success');
2445
+ process.exit(0);
2446
+ }
2447
+ catch (error) {
2448
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
2449
+ process.exit(EXIT_NO_RESTART);
2450
+ }
2451
+ });
2452
+ cli
2453
+ .command('send', 'Send a message to a Discord channel/thread. Default creates a thread; use --thread/--session to continue existing.')
2454
+ .alias('start-session') // backwards compatibility
2455
+ .option('-c, --channel <channelId>', 'Discord channel ID')
2456
+ .option('-d, --project <path>', 'Project directory (alternative to --channel)')
2457
+ .option('-p, --prompt <prompt>', 'Message content')
2458
+ .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
2459
+ .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
2460
+ .option('--notify-only', 'Create notification thread without starting AI session')
2461
+ .option('--silent-prompt', 'Hide prompt text from Discord thread (send as hidden attachment)')
2462
+ .option('--no-message', 'Create thread without visible prompt message (agent responds first)')
2463
+ .option('--worktree [name]', 'Create git worktree for session (name optional, derives from thread name)')
2464
+ .option('--cwd <path>', 'Start session in an existing git worktree directory instead of the main project directory')
2465
+ .option('-u, --user <username>', 'Discord username to add to thread')
2466
+ .option('--agent <agent>', 'Agent to use for the session')
2467
+ .option('--model <model>', 'Model to use (format: provider/model)')
2468
+ .option('--permission <rule>', z.array(z.string()).describe('Session permission rule (repeatable). Format: "tool:action" or "tool:pattern:action". ' +
2469
+ 'Actions: allow, deny, ask. Examples: --permission "bash:deny" --permission "edit:deny"'))
2470
+ .option('--injection-guard <pattern>', z.array(z.string()).describe('Injection guard scan pattern (repeatable). Enables prompt injection detection for this session. ' +
2471
+ 'Format: "tool:argsGlob". Examples: --injection-guard "bash:*" --injection-guard "webfetch:*"'))
2472
+ .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
2473
+ .option('--thread <threadId>', 'Post prompt to an existing thread')
2474
+ .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
2475
+ .option('--wait', 'Wait for session to complete, then print session text to stdout')
2476
+ .option('--report', 'Wait and stream incremental session updates for follow-ups')
2477
+ .action(async (options) => {
2478
+ try {
2479
+ // `--name` / `--app-id` are optional-value flags: `undefined` when
2480
+ // omitted, `''` when passed bare, a real string when given a value.
2481
+ // `||` collapses `''` to `undefined` for downstream consumers.
2482
+ const optionAppId = options.appId || undefined;
2483
+ let { channel: channelId, prompt, notifyOnly, thread: threadId, session: sessionId, } = options;
2484
+ let noMessage = options.noMessage;
2485
+ let silentPrompt = options.silentPrompt;
2486
+ let name = options.name || undefined;
2487
+ const { project: projectPath } = options;
2488
+ const sendAt = options.sendAt;
2489
+ const existingThreadMode = Boolean(threadId || sessionId);
2490
+ if (threadId && sessionId) {
2491
+ cliLogger.error('Use either --thread or --session, not both');
2492
+ process.exit(EXIT_NO_RESTART);
2493
+ }
2494
+ if (existingThreadMode && (channelId || projectPath)) {
2495
+ cliLogger.error('Cannot combine --thread/--session with --channel/--project');
2496
+ process.exit(EXIT_NO_RESTART);
2497
+ }
2498
+ // Default to current directory if neither --channel nor --project provided
2499
+ const resolvedProjectPath = existingThreadMode
2500
+ ? undefined
2501
+ : projectPath || (!channelId ? '.' : undefined);
2502
+ // For immediate new-thread sends, --silent-prompt should be fully silent:
2503
+ // no visible starter prompt, and agent response should be first.
2504
+ // Reuse the existing agent-first path by switching to noMessage mode.
2505
+ if (silentPrompt && !existingThreadMode && !sendAt) {
2506
+ noMessage = true;
2507
+ silentPrompt = false;
2508
+ }
2509
+ if (!prompt) {
2510
+ cliLogger.error('Prompt is required. Use --prompt <prompt>');
2511
+ process.exit(EXIT_NO_RESTART);
2512
+ }
2513
+ if (options.wait && options.report) {
2514
+ cliLogger.error('Use either --wait or --report, not both');
2515
+ process.exit(EXIT_NO_RESTART);
2516
+ }
2517
+ if (sendAt) {
2518
+ if (options.wait) {
2519
+ cliLogger.error('Cannot use --wait with --send-at');
2520
+ process.exit(EXIT_NO_RESTART);
2521
+ }
2522
+ if (options.report) {
2523
+ cliLogger.error('Cannot use --report with --send-at');
2524
+ process.exit(EXIT_NO_RESTART);
2525
+ }
2526
+ if (prompt.length > 1900) {
2527
+ cliLogger.error('--send-at currently supports prompts up to 1900 characters');
2528
+ process.exit(EXIT_NO_RESTART);
2529
+ }
2530
+ }
2531
+ const parsedSchedule = (() => {
2532
+ if (!sendAt) {
2533
+ return null;
2534
+ }
2535
+ // Cron expressions use UTC so the schedule is consistent regardless of
2536
+ // which machine runs the bot. The system message tells the model to use UTC.
2537
+ return parseSendAtValue({
2538
+ value: sendAt,
2539
+ now: new Date(),
2540
+ timezone: 'UTC',
2541
+ });
2542
+ })();
2543
+ if (parsedSchedule instanceof Error) {
2544
+ cliLogger.error(parsedSchedule.message);
2545
+ if (parsedSchedule.cause instanceof Error) {
2546
+ cliLogger.error(parsedSchedule.cause.message);
2547
+ }
2548
+ process.exit(EXIT_NO_RESTART);
2549
+ }
2550
+ if (!existingThreadMode && options.worktree && notifyOnly) {
2551
+ cliLogger.error('Cannot use --worktree with --notify-only');
2552
+ process.exit(EXIT_NO_RESTART);
2553
+ }
2554
+ if (options.cwd && options.worktree) {
2555
+ cliLogger.error('Cannot use --cwd with --worktree');
2556
+ process.exit(EXIT_NO_RESTART);
2557
+ }
2558
+ if (options.cwd && notifyOnly) {
2559
+ cliLogger.error('Cannot use --cwd with --notify-only');
2560
+ process.exit(EXIT_NO_RESTART);
2561
+ }
2562
+ if (options.wait && notifyOnly) {
2563
+ cliLogger.error('Cannot use --wait with --notify-only');
2564
+ process.exit(EXIT_NO_RESTART);
2565
+ }
2566
+ if (options.report && notifyOnly) {
2567
+ cliLogger.error('Cannot use --report with --notify-only');
2568
+ process.exit(EXIT_NO_RESTART);
2569
+ }
2570
+ if (silentPrompt && notifyOnly) {
2571
+ cliLogger.error('Cannot use --silent-prompt with --notify-only');
2572
+ process.exit(EXIT_NO_RESTART);
2573
+ }
2574
+ if (noMessage && notifyOnly) {
2575
+ cliLogger.error('Cannot use --no-message with --notify-only');
2576
+ process.exit(EXIT_NO_RESTART);
2577
+ }
2578
+ if (existingThreadMode) {
2579
+ const incompatibleFlags = [];
2580
+ if (notifyOnly) {
2581
+ incompatibleFlags.push('--notify-only');
2582
+ }
2583
+ if (options.worktree) {
2584
+ incompatibleFlags.push('--worktree');
2585
+ }
2586
+ if (options.cwd) {
2587
+ incompatibleFlags.push('--cwd');
2588
+ }
2589
+ if (name) {
2590
+ incompatibleFlags.push('--name');
2591
+ }
2592
+ if (options.user) {
2593
+ incompatibleFlags.push('--user');
2594
+ }
2595
+ if (noMessage) {
2596
+ incompatibleFlags.push('--no-message');
2597
+ }
2598
+ if (!sendAt && options.agent) {
2599
+ incompatibleFlags.push('--agent');
2600
+ }
2601
+ if (!sendAt && options.model) {
2602
+ incompatibleFlags.push('--model');
2603
+ }
2604
+ if (incompatibleFlags.length > 0) {
2605
+ cliLogger.error(`Incompatible options with --thread/--session: ${incompatibleFlags.join(', ')}`);
2606
+ process.exit(EXIT_NO_RESTART);
2607
+ }
2608
+ }
2609
+ // Initialize database first
2610
+ await initDatabase();
2611
+ const { token: botToken, appId } = await resolveBotCredentials({
2612
+ appIdOverride: optionAppId,
2613
+ });
2614
+ // If --project provided (or defaulting to cwd), resolve to channel ID
2615
+ if (resolvedProjectPath) {
2616
+ const absolutePath = path.resolve(resolvedProjectPath);
2617
+ if (!fs.existsSync(absolutePath)) {
2618
+ cliLogger.error(`Directory does not exist: ${absolutePath}`);
2619
+ process.exit(EXIT_NO_RESTART);
2620
+ }
2621
+ cliLogger.log('Looking up channel for project...');
2622
+ // Check if channel already exists for this directory or a parent directory
2623
+ // This allows running from subfolders of a registered project
2624
+ try {
2625
+ // Helper to find channel for a path.
2626
+ const findChannelForPath = async (dirPath) => {
2627
+ const channels = await findChannelsByDirectory({
2628
+ directory: dirPath,
2629
+ channelType: 'text',
2630
+ });
2631
+ return channels[0];
2632
+ };
2633
+ // Try exact match first, then walk up parent directories
2634
+ let existingChannel;
2635
+ let searchPath = absolutePath;
2636
+ while (searchPath !== path.dirname(searchPath)) {
2637
+ existingChannel = await findChannelForPath(searchPath);
2638
+ if (existingChannel)
2639
+ break;
2640
+ searchPath = path.dirname(searchPath);
2641
+ }
2642
+ if (existingChannel) {
2643
+ channelId = existingChannel.channel_id;
2644
+ if (existingChannel.directory !== absolutePath) {
2645
+ cliLogger.log(`Found parent project channel: ${existingChannel.directory}`);
2646
+ }
2647
+ else {
2648
+ cliLogger.log(`Found existing channel: ${channelId}`);
2649
+ }
2650
+ }
2651
+ else {
2652
+ // Need to create a new channel
2653
+ cliLogger.log('Creating new channel...');
2654
+ if (!appId) {
2655
+ cliLogger.log('Missing app ID');
2656
+ cliLogger.error('App ID is required to create channels. Use --app-id or run `otto gateway start` first.');
2657
+ process.exit(EXIT_NO_RESTART);
2658
+ }
2659
+ const client = await createDiscordClient();
2660
+ await new Promise((resolve, reject) => {
2661
+ client.once(Events.ClientReady, () => {
2662
+ resolve();
2663
+ });
2664
+ client.once(Events.Error, reject);
2665
+ client.login(botToken);
2666
+ });
2667
+ // Get guild from existing channels or first available
2668
+ const guild = await (async () => {
2669
+ const existingChannelId = await (await getPrisma()).channel_directories.findFirst({
2670
+ where: { channel_type: 'text' },
2671
+ orderBy: { created_at: 'desc' },
2672
+ select: { channel_id: true },
2673
+ }).then((row) => row?.channel_id);
2674
+ if (existingChannelId) {
2675
+ try {
2676
+ const ch = await client.channels.fetch(existingChannelId);
2677
+ if (ch && 'guild' in ch && ch.guild) {
2678
+ return ch.guild;
2679
+ }
2680
+ }
2681
+ catch (error) {
2682
+ cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.stack : String(error));
2683
+ }
2684
+ }
2685
+ // Fall back to first guild the bot is in
2686
+ let firstGuild = client.guilds.cache.first();
2687
+ if (!firstGuild) {
2688
+ // Cache might be empty, try fetching guilds from API
2689
+ const fetched = await client.guilds.fetch();
2690
+ const firstOAuth2Guild = fetched.first();
2691
+ if (firstOAuth2Guild) {
2692
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
2693
+ }
2694
+ }
2695
+ if (!firstGuild) {
2696
+ throw new Error('No guild found. Add the bot to a server first.');
2697
+ }
2698
+ return firstGuild;
2699
+ })();
2700
+ const { textChannelId } = await createProjectChannels({
2701
+ guild,
2702
+ projectDirectory: absolutePath,
2703
+ botName: client.user?.username,
2704
+ });
2705
+ channelId = textChannelId;
2706
+ cliLogger.log(`Created channel: ${channelId}`);
2707
+ client.destroy();
2708
+ }
2709
+ }
2710
+ catch (e) {
2711
+ cliLogger.log('Failed to resolve project');
2712
+ throw e;
2713
+ }
2714
+ }
2715
+ const rest = createDiscordRest(botToken);
2716
+ if (existingThreadMode) {
2717
+ const targetThreadId = await (async () => {
2718
+ if (threadId) {
2719
+ return threadId;
2720
+ }
2721
+ if (!sessionId) {
2722
+ throw new Error('Thread ID not resolved');
2723
+ }
2724
+ const resolvedThreadId = await getThreadIdBySessionId(sessionId);
2725
+ if (!resolvedThreadId) {
2726
+ throw new Error(`No Discord thread found for session: ${sessionId}`);
2727
+ }
2728
+ return resolvedThreadId;
2729
+ })();
2730
+ const threadData = (await rest.get(Routes.channel(targetThreadId)));
2731
+ if (!isThreadChannelType(threadData.type)) {
2732
+ throw new Error(`Channel is not a thread: ${targetThreadId}`);
2733
+ }
2734
+ if (!threadData.parent_id) {
2735
+ throw new Error(`Thread has no parent channel: ${targetThreadId}`);
2736
+ }
2737
+ const channelConfig = await getChannelDirectory(threadData.parent_id);
2738
+ if (!channelConfig) {
2739
+ throw new Error('Thread parent channel is not configured with a project directory');
2740
+ }
2741
+ if (parsedSchedule) {
2742
+ const payload = {
2743
+ kind: 'thread',
2744
+ threadId: targetThreadId,
2745
+ prompt,
2746
+ silentPrompt: Boolean(silentPrompt),
2747
+ agent: options.agent || null,
2748
+ model: options.model || null,
2749
+ username: null,
2750
+ userId: null,
2751
+ permissions: options.permission?.length ? options.permission : null,
2752
+ injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
2753
+ };
2754
+ const taskId = await createScheduledTask({
2755
+ scheduleKind: parsedSchedule.scheduleKind,
2756
+ runAt: parsedSchedule.runAt,
2757
+ cronExpr: parsedSchedule.cronExpr,
2758
+ timezone: parsedSchedule.timezone,
2759
+ nextRunAt: parsedSchedule.nextRunAt,
2760
+ payloadJson: serializeScheduledTaskPayload(payload),
2761
+ promptPreview: getPromptPreview(prompt),
2762
+ channelId: threadData.parent_id,
2763
+ threadId: targetThreadId,
2764
+ sessionId: sessionId || undefined,
2765
+ projectDirectory: channelConfig.directory,
2766
+ });
2767
+ const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
2768
+ note(`Task ID: ${taskId}\nTarget thread: ${threadData.name}\nSchedule: ${formatTaskScheduleLine(parsedSchedule)}\n\nURL: ${threadUrl}`, '✅ Task Scheduled');
2769
+ cliLogger.log(threadUrl);
2770
+ process.exit(0);
2771
+ }
2772
+ const threadPromptMarker = {
2773
+ start: true,
2774
+ ...(options.permission?.length ? { permissions: options.permission } : {}),
2775
+ ...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
2776
+ };
2777
+ const promptEmbed = [
2778
+ {
2779
+ color: 0x2b2d31,
2780
+ footer: { text: YAML.stringify(threadPromptMarker) },
2781
+ },
2782
+ ];
2783
+ // Prefix the prompt so it's clear who sent it (matches /queue format).
2784
+ // Use a newline between prefix and prompt so leading /command
2785
+ // detection can find the command on its own line.
2786
+ const prefixedPrompt = `» **otto:**\n${prompt}`;
2787
+ await sendDiscordMessageWithOptionalAttachment({
2788
+ channelId: targetThreadId,
2789
+ prompt: prefixedPrompt,
2790
+ botToken,
2791
+ embeds: promptEmbed,
2792
+ rest,
2793
+ });
2794
+ const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
2795
+ note(`Prompt sent to thread: ${threadData.name}\n\nURL: ${threadUrl}`, '✅ Message Sent');
2796
+ cliLogger.log(threadUrl);
2797
+ if (options.report) {
2798
+ const { waitAndReportSession } = await import('./wait-session.js');
2799
+ await waitAndReportSession({
2800
+ threadId: targetThreadId,
2801
+ projectDirectory: channelConfig.directory,
2802
+ });
2803
+ }
2804
+ else if (options.wait) {
2805
+ const { waitAndOutputSession } = await import('./wait-session.js');
2806
+ await waitAndOutputSession({
2807
+ threadId: targetThreadId,
2808
+ projectDirectory: channelConfig.directory,
2809
+ });
2810
+ }
2811
+ process.exit(0);
2812
+ }
2813
+ cliLogger.log('Fetching channel info...');
2814
+ if (!channelId) {
2815
+ throw new Error('Channel ID not resolved');
2816
+ }
2817
+ // Get channel info to extract directory from topic
2818
+ const channelData = (await rest.get(Routes.channel(channelId)));
2819
+ const channelConfig = await getChannelDirectory(channelData.id);
2820
+ if (!channelConfig) {
2821
+ cliLogger.log('Channel not configured');
2822
+ throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
2823
+ }
2824
+ const projectDirectory = channelConfig.directory;
2825
+ // Validate --cwd is an existing git worktree of the project
2826
+ let resolvedCwd;
2827
+ if (options.cwd) {
2828
+ const cwdResult = await validateWorktreeDirectory({
2829
+ projectDirectory,
2830
+ candidatePath: options.cwd,
2831
+ });
2832
+ if (cwdResult instanceof Error) {
2833
+ cliLogger.error(cwdResult.message);
2834
+ process.exit(EXIT_NO_RESTART);
2835
+ }
2836
+ resolvedCwd = cwdResult;
2837
+ }
2838
+ // Resolve username to user ID if provided
2839
+ const resolvedUser = await (async () => {
2840
+ if (!options.user) {
2841
+ return undefined;
2842
+ }
2843
+ cliLogger.log(`Searching for user "${options.user}" in guild...`);
2844
+ const searchResults = (await rest.get(Routes.guildMembersSearch(channelData.guild_id), {
2845
+ query: new URLSearchParams({ query: options.user, limit: '10' }),
2846
+ }));
2847
+ // Find exact match by display name, nickname, or username
2848
+ const exactMatch = searchResults.find((member) => {
2849
+ const displayName = member.nick || member.user.global_name || member.user.username;
2850
+ return (displayName.toLowerCase() === options.user.toLowerCase() ||
2851
+ member.user.username.toLowerCase() === options.user.toLowerCase());
2852
+ });
2853
+ const member = exactMatch || searchResults[0];
2854
+ if (!member) {
2855
+ throw new Error(`User "${options.user}" not found in guild`);
2856
+ }
2857
+ const username = member.nick || member.user.global_name || member.user.username;
2858
+ cliLogger.log(`Found user: ${username} (${member.user.id})`);
2859
+ return { id: member.user.id, username };
2860
+ })();
2861
+ cliLogger.log('Creating starter message...');
2862
+ // Compute thread name and worktree name early (needed for embed)
2863
+ const cleanPrompt = stripMentions(prompt);
2864
+ const baseThreadName = name ||
2865
+ (cleanPrompt.length > 80
2866
+ ? cleanPrompt.slice(0, 77) + '...'
2867
+ : cleanPrompt);
2868
+ // Explicit string => use as-is via formatWorktreeName (no vowel strip).
2869
+ // Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
2870
+ const worktreeName = options.worktree
2871
+ ? typeof options.worktree === 'string'
2872
+ ? formatWorktreeName(options.worktree)
2873
+ : formatAutoWorktreeName(baseThreadName)
2874
+ : undefined;
2875
+ const threadName = worktreeName
2876
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
2877
+ : baseThreadName;
2878
+ if (parsedSchedule) {
2879
+ const payload = {
2880
+ kind: 'channel',
2881
+ channelId,
2882
+ prompt,
2883
+ name: name || null,
2884
+ notifyOnly: Boolean(notifyOnly),
2885
+ silentPrompt: Boolean(silentPrompt),
2886
+ worktreeName: worktreeName || null,
2887
+ cwd: resolvedCwd || null,
2888
+ agent: options.agent || null,
2889
+ model: options.model || null,
2890
+ username: resolvedUser?.username || null,
2891
+ userId: resolvedUser?.id || null,
2892
+ permissions: options.permission?.length ? options.permission : null,
2893
+ injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
2894
+ };
2895
+ const taskId = await createScheduledTask({
2896
+ scheduleKind: parsedSchedule.scheduleKind,
2897
+ runAt: parsedSchedule.runAt,
2898
+ cronExpr: parsedSchedule.cronExpr,
2899
+ timezone: parsedSchedule.timezone,
2900
+ nextRunAt: parsedSchedule.nextRunAt,
2901
+ payloadJson: serializeScheduledTaskPayload(payload),
2902
+ promptPreview: getPromptPreview(prompt),
2903
+ channelId,
2904
+ projectDirectory,
2905
+ });
2906
+ const channelUrl = `https://discord.com/channels/${channelData.guild_id}/${channelId}`;
2907
+ note(`Task ID: ${taskId}\nTarget channel: #${channelData.name}\nSchedule: ${formatTaskScheduleLine(parsedSchedule)}\n\nURL: ${channelUrl}`, '✅ Task Scheduled');
2908
+ cliLogger.log(channelUrl);
2909
+ process.exit(0);
2910
+ }
2911
+ // ── New thread creation ──────────────────────────────────
2912
+ // CLI creates Discord thread with embed marker, bot's ThreadCreate
2913
+ // handler picks it up and starts the session. No IPC needed —
2914
+ // CLI and bot are separate processes with separate opencode servers.
2915
+ // Notify-only: create empty visible starter + thread, then exit
2916
+ if (notifyOnly) {
2917
+ cliLogger.log('Creating notify-only starter message...');
2918
+ const notifyStarter = (await rest.post(Routes.channelMessages(channelId), { body: { content: '' } }));
2919
+ cliLogger.log('Creating thread...');
2920
+ const notifyThread = (await rest.post(Routes.threads(channelId, notifyStarter.id), {
2921
+ body: {
2922
+ name: threadName.slice(0, 100),
2923
+ auto_archive_duration: 1440,
2924
+ },
2925
+ }));
2926
+ if (resolvedUser) {
2927
+ cliLogger.log(`Adding user ${resolvedUser.username} to thread...`);
2928
+ await rest
2929
+ .put(Routes.threadMembers(notifyThread.id, resolvedUser.id))
2930
+ .catch(() => { });
2931
+ }
2932
+ const notifyUrl = `https://discord.com/channels/${channelData.guild_id}/${notifyThread.id}`;
2933
+ note(`Thread: ${notifyThread.name || notifyThread.id}\n\nURL: ${notifyUrl}`, '✅ Thread Created');
2934
+ cliLogger.log(notifyUrl);
2935
+ process.exit(0);
2936
+ }
2937
+ // Embed marker for auto-start sessions.
2938
+ // Bot's ThreadCreate handler parses this YAML to start a session,
2939
+ // optionally create a worktree, set initial user, etc.
2940
+ const embedMarker = {
2941
+ start: true,
2942
+ ...(noMessage && { prompt }),
2943
+ ...(worktreeName && { worktree: worktreeName }),
2944
+ ...(resolvedCwd && { cwd: resolvedCwd }),
2945
+ ...(resolvedUser && {
2946
+ username: resolvedUser.username,
2947
+ userId: resolvedUser.id,
2948
+ }),
2949
+ ...(options.agent && { agent: options.agent }),
2950
+ ...(options.model && { model: options.model }),
2951
+ ...(options.permission?.length && { permissions: options.permission }),
2952
+ ...(options.injectionGuard?.length && { injectionGuardPatterns: options.injectionGuard }),
2953
+ };
2954
+ const autoStartEmbed = [
2955
+ { color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } },
2956
+ ];
2957
+ // Create starter message
2958
+ const starterBody = noMessage
2959
+ ? { content: '\u200B', flags: 4096, embeds: autoStartEmbed }
2960
+ : {
2961
+ content: prompt,
2962
+ embeds: autoStartEmbed,
2963
+ };
2964
+ cliLogger.log(noMessage
2965
+ ? 'Creating invisible starter message...'
2966
+ : 'Creating starter message...');
2967
+ const starterMessage = (await rest.post(Routes.channelMessages(channelId), { body: starterBody }));
2968
+ // Create thread from starter
2969
+ cliLogger.log('Creating thread...');
2970
+ const threadData = (await rest.post(Routes.threads(channelId, starterMessage.id), {
2971
+ body: {
2972
+ name: threadName.slice(0, 100),
2973
+ auto_archive_duration: 1440,
2974
+ },
2975
+ }));
2976
+ cliLogger.log('Thread created!');
2977
+ // Add user to thread
2978
+ if (resolvedUser) {
2979
+ cliLogger.log(`Adding user ${resolvedUser.username} to thread...`);
2980
+ await rest
2981
+ .put(Routes.threadMembers(threadData.id, resolvedUser.id))
2982
+ .catch(() => { });
2983
+ }
2984
+ // Remove the embed marker from the starter message after the bot's
2985
+ // ThreadCreate handler has had time to read it (~1.5s). This prevents
2986
+ // the YAML marker from being visible to users in Discord.
2987
+ if (!notifyOnly) {
2988
+ await new Promise((resolve) => {
2989
+ setTimeout(resolve, 1500);
2990
+ });
2991
+ const editBody = noMessage
2992
+ ? { content: '\u200B', flags: 4096, embeds: [] }
2993
+ : { embeds: [] };
2994
+ await rest
2995
+ .patch(Routes.channelMessage(channelId, starterMessage.id), {
2996
+ body: editBody,
2997
+ })
2998
+ .catch(() => { });
2999
+ }
3000
+ // Success output
3001
+ const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
3002
+ const worktreeNote = worktreeName
3003
+ ? `\nWorktree: ${worktreeName}`
3004
+ : resolvedCwd
3005
+ ? `\nWorking directory: ${resolvedCwd}`
3006
+ : '';
3007
+ note(`Thread: ${threadData.name || threadData.id}\nDirectory: ${projectDirectory}${worktreeNote}\n\nBot will start session automatically.\n\nURL: ${threadUrl}`, '✅ Thread Created');
3008
+ cliLogger.log(threadUrl);
3009
+ // Wait for session completion if --wait/--report
3010
+ if (options.report) {
3011
+ const { waitAndReportSession } = await import('./wait-session.js');
3012
+ await waitAndReportSession({
3013
+ threadId: threadData.id,
3014
+ projectDirectory,
3015
+ });
3016
+ }
3017
+ else if (options.wait) {
3018
+ const { waitAndOutputSession } = await import('./wait-session.js');
3019
+ await waitAndOutputSession({
3020
+ threadId: threadData.id,
3021
+ projectDirectory,
3022
+ });
3023
+ }
3024
+ process.exit(0);
3025
+ }
3026
+ catch (error) {
3027
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3028
+ process.exit(EXIT_NO_RESTART);
3029
+ }
3030
+ });
3031
+ cli
3032
+ .command('task list', 'List scheduled tasks created via send --send-at')
3033
+ .option('--all', 'Include terminal tasks (completed, cancelled, failed)')
3034
+ .action(async (options) => {
3035
+ try {
3036
+ await initDatabase();
3037
+ const statuses = options.all
3038
+ ? undefined
3039
+ : ['planned', 'running'];
3040
+ const tasks = await listScheduledTasks({ statuses });
3041
+ if (tasks.length === 0) {
3042
+ cliLogger.log('No scheduled tasks found');
3043
+ process.exit(0);
3044
+ }
3045
+ console.log('id | status | message | channelId | projectName | folderName | timeRemaining | firesAt | cron');
3046
+ tasks.forEach((task) => {
3047
+ const projectDirectory = task.project_directory || '';
3048
+ const projectName = projectDirectory
3049
+ ? path.basename(projectDirectory)
3050
+ : '-';
3051
+ const folderName = projectDirectory
3052
+ ? path.basename(path.dirname(projectDirectory))
3053
+ : '-';
3054
+ const firesAt = task.schedule_kind === 'at' && task.run_at
3055
+ ? task.run_at.toISOString()
3056
+ : '-';
3057
+ const cronValue = task.schedule_kind === 'cron' ? task.cron_expr || '-' : '-';
3058
+ console.log(`${task.id} | ${task.status} | ${task.prompt_preview} | ${task.channel_id || '-'} | ${projectName} | ${folderName} | ${formatRelativeTime(task.next_run_at)} | ${firesAt} | ${cronValue}`);
3059
+ });
3060
+ process.exit(0);
3061
+ }
3062
+ catch (error) {
3063
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3064
+ process.exit(EXIT_NO_RESTART);
3065
+ }
3066
+ });
3067
+ cli
3068
+ .command('task delete <id>', 'Cancel a scheduled task by ID')
3069
+ .action(async (id) => {
3070
+ try {
3071
+ const taskId = Number.parseInt(id, 10);
3072
+ if (Number.isNaN(taskId) || taskId < 1) {
3073
+ cliLogger.error(`Invalid task ID: ${id}`);
3074
+ process.exit(EXIT_NO_RESTART);
3075
+ }
3076
+ await initDatabase();
3077
+ const cancelled = await cancelScheduledTask(taskId);
3078
+ if (!cancelled) {
3079
+ cliLogger.error(`Task ${taskId} not found or already finalized`);
3080
+ process.exit(EXIT_NO_RESTART);
3081
+ }
3082
+ cliLogger.log(`Cancelled task ${taskId}`);
3083
+ process.exit(0);
3084
+ }
3085
+ catch (error) {
3086
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3087
+ process.exit(EXIT_NO_RESTART);
3088
+ }
3089
+ });
3090
+ cli
3091
+ .command('task edit <id>', 'Edit prompt or schedule of a planned task')
3092
+ .option('--prompt <prompt>', 'New prompt text')
3093
+ .option('--send-at <sendAt>', 'New schedule (UTC ISO date or cron expression)')
3094
+ .action(async (id, options) => {
3095
+ try {
3096
+ const trimmedPrompt = options.prompt === undefined ? undefined : options.prompt.trim();
3097
+ if (!trimmedPrompt && !options.sendAt) {
3098
+ cliLogger.error('Provide at least --prompt or --send-at');
3099
+ process.exit(EXIT_NO_RESTART);
3100
+ }
3101
+ if (trimmedPrompt !== undefined && trimmedPrompt.length === 0) {
3102
+ cliLogger.error('--prompt cannot be empty');
3103
+ process.exit(EXIT_NO_RESTART);
3104
+ }
3105
+ if (trimmedPrompt !== undefined && trimmedPrompt.length > 1900) {
3106
+ cliLogger.error('--prompt currently supports up to 1900 characters');
3107
+ process.exit(EXIT_NO_RESTART);
3108
+ }
3109
+ const taskId = Number.parseInt(id, 10);
3110
+ if (Number.isNaN(taskId) || taskId < 1) {
3111
+ cliLogger.error(`Invalid task ID: ${id}`);
3112
+ process.exit(EXIT_NO_RESTART);
3113
+ }
3114
+ await initDatabase();
3115
+ const task = await getScheduledTask(taskId);
3116
+ if (!task) {
3117
+ cliLogger.error(`Task ${taskId} not found`);
3118
+ process.exit(EXIT_NO_RESTART);
3119
+ }
3120
+ if (task.status !== 'planned') {
3121
+ cliLogger.error(`Task ${taskId} is ${task.status}, only planned tasks can be edited`);
3122
+ process.exit(EXIT_NO_RESTART);
3123
+ }
3124
+ const existingPayload = parseScheduledTaskPayload(task.payload_json);
3125
+ if (existingPayload instanceof Error) {
3126
+ cliLogger.error(`Failed to parse task payload: ${existingPayload.message}`);
3127
+ process.exit(EXIT_NO_RESTART);
3128
+ }
3129
+ const newPrompt = trimmedPrompt ?? existingPayload.prompt;
3130
+ const updatedPayload = {
3131
+ ...existingPayload,
3132
+ prompt: newPrompt,
3133
+ };
3134
+ const updateData = {
3135
+ taskId,
3136
+ payloadJson: serializeScheduledTaskPayload(updatedPayload),
3137
+ promptPreview: getPromptPreview(newPrompt),
3138
+ };
3139
+ if (options.sendAt) {
3140
+ const parsed = parseSendAtValue({
3141
+ value: options.sendAt,
3142
+ now: new Date(),
3143
+ timezone: 'UTC',
3144
+ });
3145
+ if (parsed instanceof Error) {
3146
+ cliLogger.error(`Invalid --send-at: ${parsed.message}`);
3147
+ process.exit(EXIT_NO_RESTART);
3148
+ }
3149
+ updateData.scheduleKind = parsed.scheduleKind;
3150
+ updateData.runAt = parsed.runAt;
3151
+ updateData.cronExpr = parsed.cronExpr;
3152
+ updateData.timezone = parsed.timezone;
3153
+ updateData.nextRunAt = parsed.nextRunAt;
3154
+ }
3155
+ const updated = await updateScheduledTask(updateData);
3156
+ if (!updated) {
3157
+ cliLogger.error(`Task ${taskId} could not be updated (status may have changed)`);
3158
+ process.exit(EXIT_NO_RESTART);
3159
+ }
3160
+ cliLogger.log(`Updated task ${taskId}`);
3161
+ process.exit(0);
3162
+ }
3163
+ catch (error) {
3164
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3165
+ process.exit(EXIT_NO_RESTART);
3166
+ }
3167
+ });
3168
+ cli
3169
+ .command('anthropic-accounts list', 'List stored Anthropic OAuth accounts used for automatic rotation')
3170
+ .action(async () => {
3171
+ const store = await loadAccountStore();
3172
+ console.log(`Store: ${accountsFilePath()}`);
3173
+ if (store.accounts.length === 0) {
3174
+ console.log('No Anthropic OAuth accounts configured.');
3175
+ process.exit(0);
3176
+ }
3177
+ store.accounts.forEach((account, index) => {
3178
+ const active = index === store.activeIndex ? '*' : ' ';
3179
+ console.log(`${active} ${index + 1}. ${accountLabel(account)}`);
3180
+ });
3181
+ process.exit(0);
3182
+ });
3183
+ cli
3184
+ .command('anthropic-accounts current', 'Show the current Anthropic OAuth account being used, if any')
3185
+ .action(async () => {
3186
+ const current = await getCurrentAnthropicAccount();
3187
+ console.log(`Store: ${accountsFilePath()}`);
3188
+ console.log(`Auth: ${authFilePath()}`);
3189
+ if (!current) {
3190
+ console.log('No active Anthropic OAuth account configured.');
3191
+ process.exit(0);
3192
+ }
3193
+ const lines = [];
3194
+ lines.push(`Current: ${accountLabel(current.account || current.auth, current.index)}`);
3195
+ if (current.account?.email) {
3196
+ lines.push(`Email: ${current.account.email}`);
3197
+ }
3198
+ else {
3199
+ lines.push('Email: unavailable');
3200
+ }
3201
+ if (current.account?.accountId) {
3202
+ lines.push(`Account ID: ${current.account.accountId}`);
3203
+ }
3204
+ if (!current.account) {
3205
+ lines.push('Rotation pool entry: not found');
3206
+ }
3207
+ console.log(lines.join('\n'));
3208
+ process.exit(0);
3209
+ });
3210
+ cli
3211
+ .command('anthropic-accounts remove <indexOrEmail>', 'Remove a stored Anthropic OAuth account from the rotation pool by index or email')
3212
+ .action(async (indexOrEmail) => {
3213
+ const value = Number(indexOrEmail);
3214
+ const store = await loadAccountStore();
3215
+ const resolvedIndex = (() => {
3216
+ if (Number.isInteger(value) && value >= 1) {
3217
+ return value - 1;
3218
+ }
3219
+ const email = indexOrEmail.trim().toLowerCase();
3220
+ if (!email) {
3221
+ return -1;
3222
+ }
3223
+ return store.accounts.findIndex((account) => {
3224
+ return account.email?.toLowerCase() === email;
3225
+ });
3226
+ })();
3227
+ if (resolvedIndex < 0) {
3228
+ cliLogger.error('Usage: otto anthropic-accounts remove <index-or-email>');
3229
+ process.exit(EXIT_NO_RESTART);
3230
+ }
3231
+ const removed = store.accounts[resolvedIndex];
3232
+ await removeAccount(resolvedIndex);
3233
+ cliLogger.log(`Removed Anthropic account ${removed ? accountLabel(removed, resolvedIndex) : indexOrEmail}`);
3234
+ process.exit(0);
3235
+ });
3236
+ cli
3237
+ .command('project add [directory]', 'Create Discord channels for a project directory (replaces legacy add-project)')
3238
+ .alias('add-project')
3239
+ .option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
3240
+ .option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
3241
+ .action(async (directory, options) => {
3242
+ const absolutePath = path.resolve(directory || '.');
3243
+ if (!fs.existsSync(absolutePath)) {
3244
+ cliLogger.error(`Directory does not exist: ${absolutePath}`);
3245
+ process.exit(EXIT_NO_RESTART);
3246
+ }
3247
+ // Initialize database
3248
+ await initDatabase();
3249
+ const { token: botToken, appId } = await resolveBotCredentials({
3250
+ appIdOverride: options.appId,
3251
+ });
3252
+ if (!appId) {
3253
+ cliLogger.error('App ID is required to create channels. Use --app-id or run `otto gateway start` first.');
3254
+ process.exit(EXIT_NO_RESTART);
3255
+ }
3256
+ cliLogger.log('Connecting to Discord...');
3257
+ const client = await createDiscordClient();
3258
+ await new Promise((resolve, reject) => {
3259
+ client.once(Events.ClientReady, () => {
3260
+ resolve();
3261
+ });
3262
+ client.once(Events.Error, reject);
3263
+ client.login(botToken);
3264
+ });
3265
+ cliLogger.log('Finding guild...');
3266
+ // Find guild
3267
+ let guild;
3268
+ if (options.guild) {
3269
+ const guildId = String(options.guild);
3270
+ const foundGuild = client.guilds.cache.get(guildId);
3271
+ if (!foundGuild) {
3272
+ cliLogger.log('Guild not found');
3273
+ cliLogger.error(`Guild not found: ${guildId}`);
3274
+ client.destroy();
3275
+ process.exit(EXIT_NO_RESTART);
3276
+ }
3277
+ guild = foundGuild;
3278
+ }
3279
+ else {
3280
+ const existingChannelId = await (await getPrisma()).channel_directories.findFirst({
3281
+ where: { channel_type: 'text' },
3282
+ orderBy: { created_at: 'desc' },
3283
+ select: { channel_id: true },
3284
+ }).then((row) => row?.channel_id);
3285
+ if (existingChannelId) {
3286
+ try {
3287
+ const ch = await client.channels.fetch(existingChannelId);
3288
+ if (ch && 'guild' in ch && ch.guild) {
3289
+ guild = ch.guild;
3290
+ }
3291
+ else {
3292
+ throw new Error('Channel has no guild');
3293
+ }
3294
+ }
3295
+ catch (error) {
3296
+ cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.stack : String(error));
3297
+ let firstGuild = client.guilds.cache.first();
3298
+ if (!firstGuild) {
3299
+ // Cache might be empty, try fetching guilds from API
3300
+ const fetched = await client.guilds.fetch();
3301
+ const firstOAuth2Guild = fetched.first();
3302
+ if (firstOAuth2Guild) {
3303
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
3304
+ }
3305
+ }
3306
+ if (!firstGuild) {
3307
+ cliLogger.log('No guild found');
3308
+ cliLogger.error('No guild found. Add the bot to a server first.');
3309
+ client.destroy();
3310
+ process.exit(EXIT_NO_RESTART);
3311
+ }
3312
+ guild = firstGuild;
3313
+ }
3314
+ }
3315
+ else {
3316
+ let firstGuild = client.guilds.cache.first();
3317
+ if (!firstGuild) {
3318
+ // Cache might be empty, try fetching guilds from API
3319
+ const fetched = await client.guilds.fetch();
3320
+ const firstOAuth2Guild = fetched.first();
3321
+ if (firstOAuth2Guild) {
3322
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
3323
+ }
3324
+ }
3325
+ if (!firstGuild) {
3326
+ cliLogger.log('No guild found');
3327
+ cliLogger.error('No guild found. Add the bot to a server first.');
3328
+ client.destroy();
3329
+ process.exit(EXIT_NO_RESTART);
3330
+ }
3331
+ guild = firstGuild;
3332
+ }
3333
+ }
3334
+ // Check if channel already exists in this guild
3335
+ cliLogger.log('Checking for existing channel...');
3336
+ try {
3337
+ const existingChannels = await findChannelsByDirectory({
3338
+ directory: absolutePath,
3339
+ channelType: 'text',
3340
+ });
3341
+ for (const existingChannel of existingChannels) {
3342
+ try {
3343
+ const ch = await client.channels.fetch(existingChannel.channel_id);
3344
+ if (ch && 'guild' in ch && ch.guild?.id === guild.id) {
3345
+ client.destroy();
3346
+ cliLogger.error(`Channel already exists for this directory in ${guild.name}. Channel ID: ${existingChannel.channel_id}`);
3347
+ process.exit(EXIT_NO_RESTART);
3348
+ }
3349
+ }
3350
+ catch (error) {
3351
+ cliLogger.debug(`Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`, error instanceof Error ? error.stack : String(error));
3352
+ }
3353
+ }
3354
+ }
3355
+ catch (error) {
3356
+ cliLogger.debug('Database lookup failed while checking existing channels:', error instanceof Error ? error.stack : String(error));
3357
+ }
3358
+ cliLogger.log(`Creating channels in ${guild.name}...`);
3359
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
3360
+ guild,
3361
+ projectDirectory: absolutePath,
3362
+ botName: client.user?.username,
3363
+ });
3364
+ client.destroy();
3365
+ cliLogger.log('Channels created!');
3366
+ const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`;
3367
+ note(`Created channels for project:\n\n📝 Text: #${channelName}\n🔊 Voice: #${channelName}\n📁 Directory: ${absolutePath}\n\nURL: ${channelUrl}`, '✅ Success');
3368
+ cliLogger.log(channelUrl);
3369
+ process.exit(0);
3370
+ });
3371
+ cli
3372
+ .command('project list', 'List all registered projects with their Discord channels')
3373
+ .option('--json', 'Output as JSON')
3374
+ .option('--prune', 'Remove stale entries whose Discord channel no longer exists')
3375
+ .action(async (options) => {
3376
+ await initDatabase();
3377
+ const prisma = await getPrisma();
3378
+ const channels = await prisma.channel_directories.findMany({
3379
+ where: { channel_type: 'text' },
3380
+ orderBy: { created_at: 'desc' },
3381
+ });
3382
+ if (channels.length === 0) {
3383
+ cliLogger.log('No projects registered');
3384
+ process.exit(0);
3385
+ }
3386
+ // Fetch Discord channel names via REST API
3387
+ const botRow = await getBotTokenWithMode();
3388
+ const rest = botRow ? createDiscordRest(botRow.token) : null;
3389
+ const enriched = await Promise.all(channels.map(async (ch) => {
3390
+ let channelName = '';
3391
+ let deleted = false;
3392
+ if (rest) {
3393
+ try {
3394
+ const data = (await rest.get(Routes.channel(ch.channel_id)));
3395
+ channelName = data.name || '';
3396
+ }
3397
+ catch (error) {
3398
+ // Only mark as deleted for Unknown Channel (10003) or 404,
3399
+ // not transient errors like rate limits or 5xx
3400
+ const isUnknownChannel = error instanceof Error &&
3401
+ 'code' in error &&
3402
+ 'status' in error &&
3403
+ (error.code === 10003 ||
3404
+ error.status === 404);
3405
+ deleted = isUnknownChannel;
3406
+ }
3407
+ }
3408
+ return { ...ch, channelName, deleted };
3409
+ }));
3410
+ // Prune stale entries if requested
3411
+ if (options.prune) {
3412
+ const stale = enriched.filter((ch) => {
3413
+ return ch.deleted;
3414
+ });
3415
+ if (stale.length === 0) {
3416
+ cliLogger.log('No stale channels to prune');
3417
+ }
3418
+ else {
3419
+ for (const ch of stale) {
3420
+ await deleteChannelDirectoryById(ch.channel_id);
3421
+ cliLogger.log(`Pruned stale channel ${ch.channel_id} (${path.basename(ch.directory)})`);
3422
+ }
3423
+ cliLogger.log(`Pruned ${stale.length} stale channel(s)`);
3424
+ }
3425
+ // Re-filter to only show live entries after pruning
3426
+ const live = enriched.filter((ch) => {
3427
+ return !ch.deleted;
3428
+ });
3429
+ if (live.length === 0) {
3430
+ cliLogger.log('No projects registered');
3431
+ process.exit(0);
3432
+ }
3433
+ enriched.length = 0;
3434
+ enriched.push(...live);
3435
+ }
3436
+ if (options.json) {
3437
+ const output = enriched.map((ch) => ({
3438
+ channel_id: ch.channel_id,
3439
+ channel_name: ch.channelName,
3440
+ directory: ch.directory,
3441
+ folder_name: path.basename(ch.directory),
3442
+ deleted: ch.deleted,
3443
+ }));
3444
+ console.log(JSON.stringify(output, null, 2));
3445
+ process.exit(0);
3446
+ }
3447
+ for (const ch of enriched) {
3448
+ const folderName = path.basename(ch.directory);
3449
+ const deletedTag = ch.deleted ? ' (deleted from Discord)' : '';
3450
+ const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id;
3451
+ console.log(`\n${channelLabel}${deletedTag}`);
3452
+ console.log(` Folder: ${folderName}`);
3453
+ console.log(` Directory: ${ch.directory}`);
3454
+ console.log(` Channel ID: ${ch.channel_id}`);
3455
+ }
3456
+ process.exit(0);
3457
+ });
3458
+ cli
3459
+ .command('project open-in-discord', 'Open the current project channel in Discord')
3460
+ .action(async () => {
3461
+ await initDatabase();
3462
+ const botRow = await getBotTokenWithMode();
3463
+ if (!botRow) {
3464
+ cliLogger.error('No bot configured. Run `otto gateway start` first.');
3465
+ process.exit(EXIT_NO_RESTART);
3466
+ }
3467
+ const { token: botToken } = botRow;
3468
+ const absolutePath = path.resolve('.');
3469
+ // Walk up parent directories to find a matching channel
3470
+ const findChannelForPath = async (dirPath) => {
3471
+ const channels = await findChannelsByDirectory({
3472
+ directory: dirPath,
3473
+ channelType: 'text',
3474
+ });
3475
+ return channels[0];
3476
+ };
3477
+ let existingChannel;
3478
+ let searchPath = absolutePath;
3479
+ do {
3480
+ existingChannel = await findChannelForPath(searchPath);
3481
+ if (existingChannel) {
3482
+ break;
3483
+ }
3484
+ const parent = path.dirname(searchPath);
3485
+ if (parent === searchPath) {
3486
+ break;
3487
+ }
3488
+ searchPath = parent;
3489
+ } while (true);
3490
+ if (!existingChannel) {
3491
+ cliLogger.error(`No project channel found for ${absolutePath}`);
3492
+ process.exit(EXIT_NO_RESTART);
3493
+ }
3494
+ // Fetch channel from Discord to get guild_id
3495
+ const rest = createDiscordRest(botToken);
3496
+ const channelData = (await rest.get(Routes.channel(existingChannel.channel_id)));
3497
+ const channelUrl = `https://discord.com/channels/${channelData.guild_id}/${channelData.id}`;
3498
+ cliLogger.log(channelUrl);
3499
+ // Open in browser if running in a TTY
3500
+ if (process.stdout.isTTY) {
3501
+ if (process.platform === 'win32') {
3502
+ spawn('cmd', ['/c', 'start', '', channelUrl], {
3503
+ detached: true,
3504
+ stdio: 'ignore',
3505
+ }).unref();
3506
+ }
3507
+ else {
3508
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
3509
+ spawn(openCmd, [channelUrl], {
3510
+ detached: true,
3511
+ stdio: 'ignore',
3512
+ }).unref();
3513
+ }
3514
+ }
3515
+ process.exit(0);
3516
+ });
3517
+ cli
3518
+ .command('project create <name>', 'Create a new project folder with git and Discord channels')
3519
+ .option('-g, --guild <guildId>', 'Discord guild ID')
3520
+ .option('--projects-dir <path>', 'Directory where new projects are created (default: <data-dir>/projects)')
3521
+ .action(async (name, options) => {
3522
+ if (options.projectsDir) {
3523
+ setProjectsDir(options.projectsDir);
3524
+ }
3525
+ const sanitizedName = name
3526
+ .toLowerCase()
3527
+ .replace(/[^a-z0-9-]/g, '-')
3528
+ .replace(/-+/g, '-')
3529
+ .replace(/^-|-$/g, '')
3530
+ .slice(0, 100);
3531
+ if (!sanitizedName) {
3532
+ cliLogger.error('Invalid project name');
3533
+ process.exit(EXIT_NO_RESTART);
3534
+ }
3535
+ await initDatabase();
3536
+ const botRow = await getBotTokenWithMode();
3537
+ if (!botRow) {
3538
+ cliLogger.error('No bot configured. Run `otto gateway start` first.');
3539
+ process.exit(EXIT_NO_RESTART);
3540
+ }
3541
+ const { token: botToken } = botRow;
3542
+ const projectsDir = getProjectsDir();
3543
+ const projectDirectory = path.join(projectsDir, sanitizedName);
3544
+ if (!fs.existsSync(projectsDir)) {
3545
+ fs.mkdirSync(projectsDir, { recursive: true });
3546
+ }
3547
+ if (fs.existsSync(projectDirectory)) {
3548
+ cliLogger.error(`Directory already exists: ${projectDirectory}`);
3549
+ process.exit(EXIT_NO_RESTART);
3550
+ }
3551
+ fs.mkdirSync(projectDirectory, { recursive: true });
3552
+ cliLogger.log(`Created: ${projectDirectory}`);
3553
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
3554
+ cliLogger.log('Initialized git');
3555
+ cliLogger.log('Connecting to Discord...');
3556
+ const client = await createDiscordClient();
3557
+ await new Promise((resolve, reject) => {
3558
+ client.once(Events.ClientReady, () => {
3559
+ resolve();
3560
+ });
3561
+ client.once(Events.Error, reject);
3562
+ client.login(botToken).catch(reject);
3563
+ });
3564
+ let guild;
3565
+ if (options.guild) {
3566
+ const found = client.guilds.cache.get(options.guild);
3567
+ if (!found) {
3568
+ cliLogger.error(`Guild not found: ${options.guild}`);
3569
+ client.destroy();
3570
+ process.exit(EXIT_NO_RESTART);
3571
+ }
3572
+ guild = found;
3573
+ }
3574
+ else {
3575
+ const first = client.guilds.cache.first();
3576
+ if (!first) {
3577
+ cliLogger.error('No guild found. Add the bot to a server first.');
3578
+ client.destroy();
3579
+ process.exit(EXIT_NO_RESTART);
3580
+ }
3581
+ guild = first;
3582
+ }
3583
+ const { textChannelId, channelName } = await createProjectChannels({
3584
+ guild,
3585
+ projectDirectory,
3586
+ botName: client.user?.username,
3587
+ });
3588
+ client.destroy();
3589
+ const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`;
3590
+ note(`Created project: ${sanitizedName}\n\nDirectory: ${projectDirectory}\nChannel: #${channelName}\nURL: ${channelUrl}`, '✅ Success');
3591
+ cliLogger.log(channelUrl);
3592
+ process.exit(0);
3593
+ });
3594
+ cli
3595
+ .command('user list', 'Search for Discord users in a guild/server. Returns user IDs for mentions.')
3596
+ .option('-g, --guild <guildId>', 'Discord guild/server ID (required)')
3597
+ .option('-q, --query [query]', 'Search query to filter users by name')
3598
+ .action(async (options) => {
3599
+ try {
3600
+ if (!options.guild) {
3601
+ cliLogger.error('Guild ID is required. Use --guild <guildId>');
3602
+ process.exit(EXIT_NO_RESTART);
3603
+ }
3604
+ const guildId = String(options.guild);
3605
+ // Bare `--query` comes through as `''`; collapse it to undefined
3606
+ const query = options.query || undefined;
3607
+ await initDatabase();
3608
+ const { token: botToken } = await resolveBotCredentials();
3609
+ const rest = createDiscordRest(botToken);
3610
+ const members = await (async () => {
3611
+ if (query) {
3612
+ return (await rest.get(Routes.guildMembersSearch(guildId), {
3613
+ query: new URLSearchParams({ query, limit: '20' }),
3614
+ }));
3615
+ }
3616
+ return (await rest.get(Routes.guildMembers(guildId), {
3617
+ query: new URLSearchParams({ limit: '20' }),
3618
+ }));
3619
+ })();
3620
+ if (members.length === 0) {
3621
+ const msg = query
3622
+ ? `No users found matching "${query}"`
3623
+ : 'No users found in guild';
3624
+ cliLogger.log(msg);
3625
+ process.exit(0);
3626
+ }
3627
+ const userList = members
3628
+ .map((m) => {
3629
+ const displayName = m.nick || m.user.global_name || m.user.username;
3630
+ return `- ${displayName} (ID: ${m.user.id}) - mention: <@${m.user.id}>`;
3631
+ })
3632
+ .join('\n');
3633
+ const header = query
3634
+ ? `Found ${members.length} users matching "${query}":`
3635
+ : `Found ${members.length} users:`;
3636
+ console.log(`${header}\n${userList}`);
3637
+ process.exit(0);
3638
+ }
3639
+ catch (error) {
3640
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3641
+ process.exit(EXIT_NO_RESTART);
3642
+ }
3643
+ });
3644
+ cli
3645
+ .command('tunnel', 'Expose a local port via tunnel')
3646
+ .option('-p, --port <port>', 'Local port to expose (required)')
3647
+ .option('-t, --tunnel-id [id]', 'Custom tunnel ID (only for services safe to expose publicly; prefer random default)')
3648
+ .option('-h, --host [host]', 'Local host (default: localhost)')
3649
+ .option('-s, --server [url]', 'Tunnel server URL')
3650
+ .option('-k, --kill', 'Kill any existing process on the port before starting')
3651
+ .action(async (options) => {
3652
+ const { runTunnel, parseCommandFromArgv, CLI_NAME } = await import('traforo/run-tunnel');
3653
+ if (!options.port) {
3654
+ cliLogger.error('Error: --port is required');
3655
+ cliLogger.error(`\nUsage: otto tunnel -p <port> [-- command]`);
3656
+ process.exit(EXIT_NO_RESTART);
3657
+ }
3658
+ const port = parseInt(options.port, 10);
3659
+ if (isNaN(port) || port < 1 || port > 65535) {
3660
+ cliLogger.error(`Error: Invalid port number: ${options.port}`);
3661
+ process.exit(EXIT_NO_RESTART);
3662
+ }
3663
+ // Parse command after -- from argv
3664
+ const { command } = parseCommandFromArgv(process.argv);
3665
+ await runTunnel({
3666
+ port,
3667
+ tunnelId: options.tunnelId || undefined,
3668
+ localHost: options.host || undefined,
3669
+ baseDomain: 'otto.dev',
3670
+ serverUrl: options.server || undefined,
3671
+ command: command.length > 0 ? command : undefined,
3672
+ kill: options.kill,
3673
+ });
3674
+ });
3675
+ cli
3676
+ .command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 30 minutes. Runs until Ctrl+C. For background usage, start with bunx tuistory --help, then run it in a tuistory session.')
3677
+ .action(async () => {
3678
+ const { startScreenshare } = await import('./commands/screenshare.js');
3679
+ try {
3680
+ const session = await startScreenshare({
3681
+ sessionKey: 'cli',
3682
+ startedBy: 'cli',
3683
+ });
3684
+ cliLogger.log(`Screen sharing started: ${session.noVncUrl}`);
3685
+ cliLogger.log('Press Ctrl+C to stop');
3686
+ }
3687
+ catch (err) {
3688
+ cliLogger.error('Failed to start screen share:', err instanceof Error ? err.message : String(err));
3689
+ process.exit(EXIT_NO_RESTART);
3690
+ }
3691
+ });
3692
+ cli
3693
+ .command('sqlitedb', 'Show the location of the SQLite database file')
3694
+ .action(() => {
3695
+ const dataDir = getDataDir();
3696
+ const dbPath = path.join(dataDir, 'discord-sessions.db');
3697
+ cliLogger.log(dbPath);
3698
+ });
3699
+ cli
3700
+ .command('session list', 'List all OpenCode sessions, marking which were started via Otto')
3701
+ .option('--project <path>', 'Project directory to list sessions for (defaults to cwd)')
3702
+ .option('--json', 'Output as JSON')
3703
+ .action(async (options) => {
3704
+ try {
3705
+ const projectDirectory = path.resolve(options.project || '.');
3706
+ await initDatabase();
3707
+ cliLogger.log('Connecting to OpenCode server...');
3708
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
3709
+ if (getClient instanceof Error) {
3710
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message);
3711
+ process.exit(EXIT_NO_RESTART);
3712
+ }
3713
+ const sessionsResponse = await getClient().session.list();
3714
+ const sessions = sessionsResponse.data || [];
3715
+ if (sessions.length === 0) {
3716
+ cliLogger.log('No sessions found');
3717
+ process.exit(0);
3718
+ }
3719
+ // Look up which sessions were started via otto (have a thread mapping)
3720
+ const prisma = await getPrisma();
3721
+ const threadSessions = await prisma.thread_sessions.findMany({
3722
+ select: { thread_id: true, session_id: true },
3723
+ });
3724
+ const sessionToThread = new Map(threadSessions
3725
+ .filter((row) => row.session_id !== '')
3726
+ .map((row) => [row.session_id, row.thread_id]));
3727
+ const sessionStartSources = await getSessionStartSourcesBySessionIds(sessions.map((session) => session.id));
3728
+ const scheduleModeLabel = ({ scheduleKind, }) => {
3729
+ if (scheduleKind === 'at') {
3730
+ return 'delay';
3731
+ }
3732
+ return 'cron';
3733
+ };
3734
+ if (options.json) {
3735
+ const output = sessions.map((session) => {
3736
+ const startSource = sessionStartSources.get(session.id);
3737
+ const startedBy = startSource
3738
+ ? `scheduled-${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}`
3739
+ : null;
3740
+ return {
3741
+ id: session.id,
3742
+ title: session.title || 'Untitled Session',
3743
+ directory: session.directory,
3744
+ updated: new Date(session.time.updated).toISOString(),
3745
+ source: sessionToThread.has(session.id) ? 'otto' : 'opencode',
3746
+ threadId: sessionToThread.get(session.id) || null,
3747
+ startedBy,
3748
+ scheduledTaskId: startSource?.scheduled_task_id || null,
3749
+ };
3750
+ });
3751
+ console.log(JSON.stringify(output, null, 2));
3752
+ process.exit(0);
3753
+ }
3754
+ for (const session of sessions) {
3755
+ const threadId = sessionToThread.get(session.id);
3756
+ const startSource = sessionStartSources.get(session.id);
3757
+ const source = threadId ? '(otto)' : '(opencode)';
3758
+ const startedBy = startSource
3759
+ ? ` | started-by: ${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}${startSource.scheduled_task_id ? ` (#${startSource.scheduled_task_id})` : ''}`
3760
+ : '';
3761
+ const updatedAt = new Date(session.time.updated).toISOString();
3762
+ const threadInfo = threadId ? ` | thread: ${threadId}` : '';
3763
+ console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${threadInfo}${startedBy}`);
3764
+ }
3765
+ process.exit(0);
3766
+ }
3767
+ catch (error) {
3768
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3769
+ process.exit(EXIT_NO_RESTART);
3770
+ }
3771
+ });
3772
+ cli
3773
+ .command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
3774
+ .option('--project <path>', 'Project directory (defaults to cwd)')
3775
+ .action(async (sessionId, options) => {
3776
+ try {
3777
+ const projectDirectory = path.resolve(options.project || '.');
3778
+ await initDatabase();
3779
+ cliLogger.log('Connecting to OpenCode server...');
3780
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
3781
+ if (getClient instanceof Error) {
3782
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message);
3783
+ process.exit(EXIT_NO_RESTART);
3784
+ }
3785
+ // Try current project first (fast path)
3786
+ const markdown = new ShareMarkdown(getClient());
3787
+ const result = await markdown.generate({ sessionID: sessionId });
3788
+ if (!(result instanceof Error)) {
3789
+ process.stdout.write(result);
3790
+ process.exit(0);
3791
+ }
3792
+ // Session not found in current project, search across all projects.
3793
+ // project.list() returns all known projects globally from any OpenCode server,
3794
+ // but session.list/get are scoped to the server's own project. So we try each.
3795
+ cliLogger.log('Session not in current project, searching all projects...');
3796
+ const projectsResponse = await getClient().project.list();
3797
+ const projects = projectsResponse.data || [];
3798
+ const otherProjects = projects
3799
+ .filter((p) => path.resolve(p.worktree) !== projectDirectory)
3800
+ .filter((p) => {
3801
+ try {
3802
+ fs.accessSync(p.worktree, fs.constants.R_OK);
3803
+ return true;
3804
+ }
3805
+ catch {
3806
+ return false;
3807
+ }
3808
+ })
3809
+ // Sort by most recently created first to find sessions faster
3810
+ .sort((a, b) => b.time.created - a.time.created);
3811
+ for (const project of otherProjects) {
3812
+ const dir = project.worktree;
3813
+ cliLogger.log(`Trying project: ${dir}`);
3814
+ const otherClient = await initializeOpencodeForDirectory(dir);
3815
+ if (otherClient instanceof Error) {
3816
+ continue;
3817
+ }
3818
+ const otherMarkdown = new ShareMarkdown(otherClient());
3819
+ const otherResult = await otherMarkdown.generate({
3820
+ sessionID: sessionId,
3821
+ });
3822
+ if (!(otherResult instanceof Error)) {
3823
+ process.stdout.write(otherResult);
3824
+ process.exit(0);
3825
+ }
3826
+ }
3827
+ cliLogger.error(`Session ${sessionId} not found in any project`);
3828
+ process.exit(EXIT_NO_RESTART);
3829
+ }
3830
+ catch (error) {
3831
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3832
+ process.exit(EXIT_NO_RESTART);
3833
+ }
3834
+ });
3835
+ cli
3836
+ .command('session search <query>', 'Search past sessions for text or /regex/flags in the selected project')
3837
+ .option('--project <path>', 'Project directory (defaults to cwd)')
3838
+ .option('--channel <channelId>', 'Resolve project from a Discord channel ID')
3839
+ .option('--limit <n>', 'Maximum matched sessions to return (default: 20)')
3840
+ .option('--json', 'Output as JSON')
3841
+ .action(async (query, options) => {
3842
+ try {
3843
+ await initDatabase();
3844
+ if (options.project && options.channel) {
3845
+ cliLogger.error('Use either --project or --channel, not both');
3846
+ process.exit(EXIT_NO_RESTART);
3847
+ }
3848
+ const limit = (() => {
3849
+ const rawLimit = typeof options.limit === 'string' ? options.limit : '20';
3850
+ const parsed = Number.parseInt(rawLimit, 10);
3851
+ if (Number.isNaN(parsed) || parsed < 1) {
3852
+ return new Error(`Invalid --limit value: ${rawLimit}`);
3853
+ }
3854
+ return parsed;
3855
+ })();
3856
+ if (limit instanceof Error) {
3857
+ cliLogger.error(limit.message);
3858
+ process.exit(EXIT_NO_RESTART);
3859
+ }
3860
+ const projectDirectoryResult = await (async () => {
3861
+ if (options.channel) {
3862
+ const channelConfig = await getChannelDirectory(options.channel);
3863
+ if (!channelConfig) {
3864
+ return new Error(`No project mapping found for channel: ${options.channel}`);
3865
+ }
3866
+ return path.resolve(channelConfig.directory);
3867
+ }
3868
+ return path.resolve(options.project || '.');
3869
+ })();
3870
+ if (projectDirectoryResult instanceof Error) {
3871
+ cliLogger.error(projectDirectoryResult.message);
3872
+ process.exit(EXIT_NO_RESTART);
3873
+ }
3874
+ const projectDirectory = projectDirectoryResult;
3875
+ if (!fs.existsSync(projectDirectory)) {
3876
+ cliLogger.error(`Directory does not exist: ${projectDirectory}`);
3877
+ process.exit(EXIT_NO_RESTART);
3878
+ }
3879
+ const searchPattern = parseSessionSearchPattern(query);
3880
+ if (searchPattern instanceof Error) {
3881
+ cliLogger.error(searchPattern.message);
3882
+ process.exit(EXIT_NO_RESTART);
3883
+ }
3884
+ cliLogger.log('Connecting to OpenCode server...');
3885
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
3886
+ if (getClient instanceof Error) {
3887
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message);
3888
+ process.exit(EXIT_NO_RESTART);
3889
+ }
3890
+ const sessionsResponse = await getClient().session.list();
3891
+ const sessions = sessionsResponse.data || [];
3892
+ if (sessions.length === 0) {
3893
+ cliLogger.log('No sessions found');
3894
+ process.exit(0);
3895
+ }
3896
+ const prisma = await getPrisma();
3897
+ const threadSessions = await prisma.thread_sessions.findMany({
3898
+ select: { thread_id: true, session_id: true },
3899
+ });
3900
+ const sessionToThread = new Map(threadSessions
3901
+ .filter((row) => row.session_id !== '')
3902
+ .map((row) => [row.session_id, row.thread_id]));
3903
+ const sortedSessions = [...sessions].sort((a, b) => {
3904
+ return b.time.updated - a.time.updated;
3905
+ });
3906
+ const matchedSessions = [];
3907
+ let scannedSessions = 0;
3908
+ for (const session of sortedSessions) {
3909
+ scannedSessions++;
3910
+ const messagesResponse = await getClient().session.messages({
3911
+ sessionID: session.id,
3912
+ });
3913
+ const messages = messagesResponse.data || [];
3914
+ const snippets = messages
3915
+ .flatMap((message) => {
3916
+ const rolePrefix = message.info.role === 'assistant'
3917
+ ? 'assistant'
3918
+ : message.info.role === 'user'
3919
+ ? 'user'
3920
+ : 'message';
3921
+ return message.parts.filter((p) => !(p.type === 'text' && p.synthetic)).flatMap((part) => {
3922
+ return getPartSearchTexts(part).flatMap((text) => {
3923
+ const hit = findFirstSessionSearchHit({
3924
+ text,
3925
+ searchPattern,
3926
+ });
3927
+ if (!hit) {
3928
+ return [];
3929
+ }
3930
+ const snippet = buildSessionSearchSnippet({ text, hit });
3931
+ if (!snippet) {
3932
+ return [];
3933
+ }
3934
+ return [`${rolePrefix}: ${snippet}`];
3935
+ });
3936
+ });
3937
+ })
3938
+ .slice(0, 3);
3939
+ if (snippets.length === 0) {
3940
+ continue;
3941
+ }
3942
+ const threadId = sessionToThread.get(session.id);
3943
+ matchedSessions.push({
3944
+ id: session.id,
3945
+ title: session.title || 'Untitled Session',
3946
+ directory: session.directory,
3947
+ updated: new Date(session.time.updated).toISOString(),
3948
+ source: threadId ? 'otto' : 'opencode',
3949
+ threadId: threadId || null,
3950
+ snippets,
3951
+ });
3952
+ if (matchedSessions.length >= limit) {
3953
+ break;
3954
+ }
3955
+ }
3956
+ if (options.json) {
3957
+ console.log(JSON.stringify({
3958
+ query: searchPattern.raw,
3959
+ mode: searchPattern.mode,
3960
+ projectDirectory,
3961
+ scannedSessions,
3962
+ matches: matchedSessions,
3963
+ }, null, 2));
3964
+ process.exit(0);
3965
+ }
3966
+ if (matchedSessions.length === 0) {
3967
+ cliLogger.log(`No matches found for ${searchPattern.raw} in ${projectDirectory} (${scannedSessions} sessions scanned)`);
3968
+ process.exit(0);
3969
+ }
3970
+ cliLogger.log(`Found ${matchedSessions.length} matching session(s) for ${searchPattern.raw} in ${projectDirectory}`);
3971
+ for (const match of matchedSessions) {
3972
+ const threadInfo = match.threadId ? ` | thread: ${match.threadId}` : '';
3973
+ console.log(`${match.id} | ${match.title} | ${match.updated} | ${match.source}${threadInfo}`);
3974
+ console.log(` Directory: ${match.directory}`);
3975
+ match.snippets.forEach((snippet) => {
3976
+ console.log(` - ${snippet}`);
3977
+ });
3978
+ }
3979
+ process.exit(0);
3980
+ }
3981
+ catch (error) {
3982
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
3983
+ process.exit(EXIT_NO_RESTART);
3984
+ }
3985
+ });
3986
+ cli
3987
+ .command('session export-events-jsonl', 'Export persisted session events from SQLite to JSONL for debugging Otto runtime bugs')
3988
+ .option('--session <sessionId>', 'Session ID whose persisted event stream should be exported')
3989
+ .option('--out <file>', 'Output .jsonl path (useful for reproducing Otto issues in event-stream-state tests)')
3990
+ .action(async (options) => {
3991
+ const sessionId = typeof options.session === 'string' ? options.session.trim() : '';
3992
+ if (!sessionId) {
3993
+ cliLogger.error('Missing --session value');
3994
+ process.exit(EXIT_NO_RESTART);
3995
+ }
3996
+ const outFile = typeof options.out === 'string' ? options.out.trim() : '';
3997
+ if (!outFile) {
3998
+ cliLogger.error('Missing --out value');
3999
+ process.exit(EXIT_NO_RESTART);
4000
+ }
4001
+ if (path.extname(outFile).toLowerCase() !== '.jsonl') {
4002
+ cliLogger.error('--out must point to a .jsonl file');
4003
+ process.exit(EXIT_NO_RESTART);
4004
+ }
4005
+ const outPath = path.resolve(outFile);
4006
+ const rows = await getSessionEventSnapshot({ sessionId });
4007
+ if (rows.length === 0) {
4008
+ cliLogger.error(`No persisted events found for session ${sessionId}. The session may not have emitted events yet.`);
4009
+ process.exit(EXIT_NO_RESTART);
4010
+ }
4011
+ const parsedRows = rows.flatMap((row) => {
4012
+ const parsed = errore.try({
4013
+ try: () => {
4014
+ return JSON.parse(row.event_json);
4015
+ },
4016
+ catch: (error) => {
4017
+ return new Error('Failed to parse persisted event JSON', {
4018
+ cause: error,
4019
+ });
4020
+ },
4021
+ });
4022
+ if (parsed instanceof Error) {
4023
+ cliLogger.warn(`Skipping invalid persisted event row ${row.id}: ${parsed.message}`);
4024
+ return [];
4025
+ }
4026
+ return [{ row, event: parsed }];
4027
+ });
4028
+ if (parsedRows.length === 0) {
4029
+ cliLogger.error(`No valid persisted events found for session ${sessionId}.`);
4030
+ process.exit(EXIT_NO_RESTART);
4031
+ }
4032
+ const projectDirectory = parsedRows.reduce((directory, { event }) => {
4033
+ if (directory) {
4034
+ return directory;
4035
+ }
4036
+ if (event.type !== 'session.updated') {
4037
+ return directory;
4038
+ }
4039
+ return event.properties.info.directory;
4040
+ }, '');
4041
+ const lines = parsedRows.map(({ row, event }) => {
4042
+ return JSON.stringify(buildOpencodeEventLogLine({
4043
+ timestamp: Number(row.timestamp),
4044
+ threadId: row.thread_id,
4045
+ projectDirectory,
4046
+ event,
4047
+ }));
4048
+ });
4049
+ const jsonl = `${lines.join('\n')}${lines.length > 0 ? '\n' : ''}`;
4050
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
4051
+ fs.writeFileSync(outPath, jsonl, 'utf8');
4052
+ cliLogger.log(`Exported ${lines.length} events from ${sessionId} to ${outPath}`);
4053
+ process.exit(0);
4054
+ });
4055
+ cli
4056
+ .command('thread-deletion-sync [mode]', 'Get or set OpenCode sync behavior when Discord threads are deleted')
4057
+ .option('--reset', 'Reset to default soft mode')
4058
+ .action(async (modeArg, options) => {
4059
+ try {
4060
+ await initDatabase();
4061
+ const credentials = await getBotTokenWithMode();
4062
+ if (!credentials?.appId) {
4063
+ cliLogger.error('No bot configured. Start otto first.');
4064
+ process.exit(EXIT_NO_RESTART);
4065
+ }
4066
+ if (options.reset) {
4067
+ await resetThreadDeletionSyncMode({ appId: credentials.appId });
4068
+ note('Thread deletion sync mode reset to soft (default).', 'Thread deletion sync');
4069
+ process.exit(0);
4070
+ }
4071
+ if (!modeArg) {
4072
+ const currentMode = await getThreadDeletionSyncMode({
4073
+ appId: credentials.appId,
4074
+ });
4075
+ note(`Current mode: ${currentMode}`, 'Thread deletion sync');
4076
+ process.exit(0);
4077
+ }
4078
+ if (modeArg !== 'soft' && modeArg !== 'hard') {
4079
+ cliLogger.error('Invalid mode. Use "soft" or "hard".');
4080
+ process.exit(EXIT_NO_RESTART);
4081
+ }
4082
+ await setThreadDeletionSyncMode({
4083
+ appId: credentials.appId,
4084
+ mode: modeArg,
4085
+ });
4086
+ note(`Set mode to ${modeArg}`, 'Thread deletion sync');
4087
+ process.exit(0);
4088
+ }
4089
+ catch (error) {
4090
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
4091
+ process.exit(EXIT_NO_RESTART);
4092
+ }
4093
+ });
4094
+ cli
4095
+ .command('session archive [threadId]', 'Archive a Discord thread and stop its mapped OpenCode session')
4096
+ .option('--session <sessionId>', 'Resolve thread from an OpenCode session ID')
4097
+ .action(async (threadIdArg, options) => {
4098
+ try {
4099
+ await initDatabase();
4100
+ // Resolve threadId from --session or positional arg
4101
+ if (threadIdArg && options.session) {
4102
+ cliLogger.error('Use either a thread ID or --session, not both');
4103
+ process.exit(EXIT_NO_RESTART);
4104
+ }
4105
+ const resolvedThreadId = await (async () => {
4106
+ if (threadIdArg) {
4107
+ return threadIdArg;
4108
+ }
4109
+ if (options.session) {
4110
+ const id = await getThreadIdBySessionId(options.session);
4111
+ if (!id) {
4112
+ cliLogger.error(`No Discord thread found for session: ${options.session}`);
4113
+ process.exit(EXIT_NO_RESTART);
4114
+ }
4115
+ return id;
4116
+ }
4117
+ cliLogger.error('Provide a thread ID or --session <sessionId>');
4118
+ process.exit(EXIT_NO_RESTART);
4119
+ })();
4120
+ const { token: botToken } = await resolveBotCredentials();
4121
+ const rest = createDiscordRest(botToken);
4122
+ const threadData = (await rest.get(Routes.channel(resolvedThreadId)));
4123
+ if (!isThreadChannelType(threadData.type)) {
4124
+ cliLogger.error(`Channel is not a thread: ${resolvedThreadId}`);
4125
+ process.exit(EXIT_NO_RESTART);
4126
+ }
4127
+ const sessionId = options.session || await getThreadSession(resolvedThreadId);
4128
+ let client = null;
4129
+ if (sessionId && threadData.parent_id) {
4130
+ const channelConfig = await getChannelDirectory(threadData.parent_id);
4131
+ if (!channelConfig) {
4132
+ cliLogger.warn(`No channel directory mapping found for parent channel ${threadData.parent_id}`);
4133
+ }
4134
+ else {
4135
+ const getClient = await initializeOpencodeForDirectory(channelConfig.directory);
4136
+ if (getClient instanceof Error) {
4137
+ cliLogger.warn(`Could not initialize OpenCode for ${channelConfig.directory}: ${getClient.message}`);
4138
+ }
4139
+ else {
4140
+ client = getClient();
4141
+ }
4142
+ }
4143
+ }
4144
+ else {
4145
+ cliLogger.warn(`No mapped OpenCode session found for thread ${resolvedThreadId}`);
4146
+ }
4147
+ await archiveThread({
4148
+ rest,
4149
+ threadId: resolvedThreadId,
4150
+ parentChannelId: threadData.parent_id,
4151
+ sessionId,
4152
+ client,
4153
+ });
4154
+ const threadLabel = threadData.name || resolvedThreadId;
4155
+ note(`Archived thread: ${threadLabel}\nThread ID: ${resolvedThreadId}`, '✅ Archived');
4156
+ process.exit(0);
4157
+ }
4158
+ catch (error) {
4159
+ cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
4160
+ process.exit(EXIT_NO_RESTART);
4161
+ }
4162
+ });
4163
+ cli
4164
+ .command('session discord-url <sessionId>', 'Print the Discord thread URL for a session')
4165
+ .option('--json', 'Output as JSON')
4166
+ .action(async (sessionId, options) => {
4167
+ await initDatabase();
4168
+ const threadId = await getThreadIdBySessionId(sessionId);
4169
+ if (!threadId) {
4170
+ cliLogger.error(`No Discord thread found for session: ${sessionId}`);
4171
+ process.exit(EXIT_NO_RESTART);
4172
+ }
4173
+ const { token: botToken } = await resolveBotCredentials();
4174
+ const rest = createDiscordRest(botToken);
4175
+ const threadData = (await rest.get(Routes.channel(threadId)));
4176
+ const url = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
4177
+ if (options.json) {
4178
+ console.log(JSON.stringify({
4179
+ url,
4180
+ threadId: threadData.id,
4181
+ guildId: threadData.guild_id,
4182
+ sessionId,
4183
+ threadName: threadData.name,
4184
+ }));
4185
+ }
4186
+ else {
4187
+ console.log(url);
4188
+ }
4189
+ process.exit(0);
4190
+ });
4191
+ cli
4192
+ .command('upgrade', 'Upgrade otto to the latest version and restart the running bot')
4193
+ .option('--skip-restart', 'Only upgrade, do not restart the running bot')
4194
+ .action(async (options) => {
4195
+ try {
4196
+ const current = getCurrentVersion();
4197
+ cliLogger.log(`Current version: v${current}`);
4198
+ const newVersion = await upgrade();
4199
+ if (!newVersion) {
4200
+ cliLogger.log('Already on latest version');
4201
+ process.exit(0);
4202
+ }
4203
+ cliLogger.log(`Upgraded to v${newVersion}`);
4204
+ if (options.skipRestart) {
4205
+ process.exit(0);
4206
+ }
4207
+ // Spawn `otto gateway start`; the new process evicts the prior instance via the
4208
+ // single-instance lock. No extra subcommands so we never re-enter `upgrade`.
4209
+ const detachedGateway = spawnDetachedGatewayStart({});
4210
+ if (detachedGateway instanceof Error) {
4211
+ throw detachedGateway;
4212
+ }
4213
+ cliLogger.log('Restarting bot with new version...');
4214
+ process.exit(0);
4215
+ }
4216
+ catch (error) {
4217
+ cliLogger.error('Upgrade failed:', error instanceof Error ? error.stack : String(error));
4218
+ process.exit(EXIT_NO_RESTART);
4219
+ }
4220
+ });
4221
+ cli
4222
+ .command('worktree merge', 'Merge worktree branch into default branch using worktrunk-style pipeline')
4223
+ .option('-d, --directory <path>', 'Worktree directory (defaults to cwd)')
4224
+ .option('-m, --main-repo <path>', 'Main repository directory (auto-detected from worktree)')
4225
+ .option('-n, --name <name>', 'Worktree/branch name (auto-detected from branch)')
4226
+ .action(async (options) => {
4227
+ try {
4228
+ const { mergeWorktree } = await import('./worktrees.js');
4229
+ const worktreeDir = path.resolve(options.directory || '.');
4230
+ // Auto-detect main repo: find the main worktree's toplevel.
4231
+ // For linked worktrees, --git-common-dir points to the shared .git,
4232
+ // and the main worktree's toplevel is one level up from that (non-bare)
4233
+ // or the dir itself (bare). We use git's worktree list to get the
4234
+ // main worktree path reliably.
4235
+ let mainRepoDir = options.mainRepo;
4236
+ if (!mainRepoDir) {
4237
+ try {
4238
+ // `git worktree list --porcelain` first line is always the main worktree
4239
+ const { stdout } = await execAsync(`git -C "${worktreeDir}" worktree list --porcelain`);
4240
+ const firstLine = stdout.split('\n')[0] || '';
4241
+ // Format: "worktree /path/to/main"
4242
+ mainRepoDir = firstLine.replace(/^worktree\s+/, '').trim();
4243
+ }
4244
+ catch {
4245
+ // Fallback: derive from git common dir
4246
+ const { stdout: commonDir } = await execAsync(`git -C "${worktreeDir}" rev-parse --git-common-dir`);
4247
+ const resolved = path.isAbsolute(commonDir.trim())
4248
+ ? commonDir.trim()
4249
+ : path.resolve(worktreeDir, commonDir.trim());
4250
+ mainRepoDir = path.dirname(resolved);
4251
+ }
4252
+ }
4253
+ // Auto-detect branch name if not provided
4254
+ let worktreeName = options.name;
4255
+ if (!worktreeName) {
4256
+ try {
4257
+ const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
4258
+ worktreeName = stdout.trim();
4259
+ }
4260
+ catch {
4261
+ worktreeName = path.basename(worktreeDir);
4262
+ }
4263
+ }
4264
+ cliLogger.log(`Worktree: ${worktreeDir}`);
4265
+ cliLogger.log(`Main repo: ${mainRepoDir}`);
4266
+ cliLogger.log(`Branch: ${worktreeName}`);
4267
+ const { RebaseConflictError } = await import('./errors.js');
4268
+ const result = await mergeWorktree({
4269
+ worktreeDir,
4270
+ mainRepoDir,
4271
+ worktreeName,
4272
+ onProgress: (msg) => {
4273
+ cliLogger.log(msg);
4274
+ },
4275
+ });
4276
+ if (result instanceof Error) {
4277
+ cliLogger.error(`Merge failed: ${result.message}`);
4278
+ if (result instanceof RebaseConflictError) {
4279
+ cliLogger.log('Resolve the rebase conflicts, then run this command again.');
4280
+ }
4281
+ process.exit(1);
4282
+ }
4283
+ cliLogger.log(`Merged ${result.branchName} into ${result.defaultBranch} @ ${result.shortSha} (${result.commitCount} commit${result.commitCount === 1 ? '' : 's'})`);
4284
+ process.exit(0);
4285
+ }
4286
+ catch (error) {
4287
+ cliLogger.error('Merge failed:', error instanceof Error ? error.stack : String(error));
4288
+ process.exit(EXIT_NO_RESTART);
644
4289
  }
645
- }
646
- main().catch((err) => {
647
- const msg = err instanceof Error ? err.message : String(err);
648
- console.error(`Error: ${msg}`);
649
- process.exit(1);
650
4290
  });
651
- //# sourceMappingURL=cli.js.map
4291
+ cli.version(getCurrentVersion());
4292
+ cli.help();
4293
+ cli.parse();