@otto-assistant/bridge 0.4.92

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