@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
package/src/cli.ts ADDED
@@ -0,0 +1,3605 @@
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 {
7
+ intro,
8
+ outro,
9
+ text,
10
+ password,
11
+ note,
12
+ cancel,
13
+ isCancel,
14
+ confirm,
15
+ log,
16
+ multiselect,
17
+ } from '@clack/prompts'
18
+ import {
19
+ deduplicateByKey,
20
+ generateBotInstallUrl,
21
+ abbreviatePath,
22
+ } from './utils.js'
23
+ import {
24
+ getChannelsWithDescriptions,
25
+ createDiscordClient,
26
+ initDatabase,
27
+ getChannelDirectory,
28
+ startDiscordBot,
29
+ initializeOpencodeForDirectory,
30
+ ensureKimakiCategory,
31
+ createProjectChannels,
32
+ type ChannelWithTags,
33
+ } from './discord-bot.js'
34
+ import {
35
+ setBotToken,
36
+ setChannelDirectory,
37
+ findChannelsByDirectory,
38
+ findChannelByAppId,
39
+ getThreadSession,
40
+ getThreadIdBySessionId,
41
+ getPrisma,
42
+ createScheduledTask,
43
+ listScheduledTasks,
44
+ cancelScheduledTask,
45
+ getSessionStartSourcesBySessionIds,
46
+ } from './database.js'
47
+ import { getBotToken, appIdFromToken } from './bot-token.js'
48
+ import { createDiscordRest, getDiscordApiV10BaseUrl } from './discord-api.js'
49
+ import { ShareMarkdown } from './markdown.js'
50
+ import {
51
+ parseSessionSearchPattern,
52
+ findFirstSessionSearchHit,
53
+ buildSessionSearchSnippet,
54
+ getPartSearchTexts,
55
+ } from './session-search.js'
56
+ import { formatWorktreeName } from './commands/worktree.js'
57
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
58
+ import type { ThreadStartMarker } from './system-message.js'
59
+ import yaml from 'js-yaml'
60
+ import type {
61
+ OpencodeClient,
62
+ Command as OpencodeCommand,
63
+ } from '@opencode-ai/sdk/v2'
64
+ import {
65
+ Events,
66
+ ChannelType,
67
+ type CategoryChannel,
68
+ type Guild,
69
+ REST,
70
+ Routes,
71
+ SlashCommandBuilder,
72
+ AttachmentBuilder,
73
+ } from 'discord.js'
74
+ import path from 'node:path'
75
+ import fs from 'node:fs'
76
+ import * as errore from 'errore'
77
+
78
+ import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './logger.js'
79
+ import { initSentry, notifyError } from './sentry.js'
80
+ import {
81
+ archiveThread,
82
+ uploadFilesToDiscord,
83
+ stripMentions,
84
+ } from './discord-utils.js'
85
+ import { spawn, execSync, type ExecSyncOptions } from 'node:child_process'
86
+
87
+ import {
88
+ setDataDir,
89
+ getDataDir,
90
+ setDefaultVerbosity,
91
+ setDefaultMentionMode,
92
+ setCritiqueEnabled,
93
+ setVerboseOpencodeServer,
94
+ getProjectsDir,
95
+ } from './config.js'
96
+ import { sanitizeAgentName } from './commands/agent.js'
97
+ import { execAsync } from './worktree-utils.js'
98
+ import {
99
+ backgroundUpgradeKimaki,
100
+ upgrade,
101
+ getCurrentVersion,
102
+ } from './upgrade.js'
103
+
104
+ import { startHranaServer } from './hrana-server.js'
105
+ import { startIpcPolling, stopIpcPolling } from './ipc-polling.js'
106
+ import {
107
+ getLocalTimeZone,
108
+ getPromptPreview,
109
+ parseSendAtValue,
110
+ serializeScheduledTaskPayload,
111
+ type ParsedSendAt,
112
+ type ScheduledTaskPayload,
113
+ } from './task-schedule.js'
114
+
115
+ const cliLogger = createLogger(LogPrefix.CLI)
116
+
117
+ // Strip bracketed paste escape sequences from terminal input.
118
+ // iTerm2 and other terminals wrap pasted content with \x1b[200~ and \x1b[201~
119
+ // which can cause validation to fail on macOS. See: https://github.com/remorses/kimaki/issues/18
120
+ function stripBracketedPaste(value: string | undefined): string {
121
+ if (!value) {
122
+ return ''
123
+ }
124
+ return value
125
+ .replace(/\x1b\[200~/g, '')
126
+ .replace(/\x1b\[201~/g, '')
127
+ .trim()
128
+ }
129
+
130
+
131
+ // Resolve bot token and app ID from env var or database.
132
+ // Used by CLI subcommands (send, project add) that need credentials
133
+ // but don't run the interactive wizard.
134
+ async function resolveBotCredentials({ appIdOverride }: { appIdOverride?: string } = {}): Promise<{
135
+ token: string
136
+ appId: string | undefined
137
+ }> {
138
+ const botCredentials = getBotToken({ appIdOverride })
139
+ if (!botCredentials) {
140
+ cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.')
141
+ process.exit(EXIT_NO_RESTART)
142
+ }
143
+ return { token: botCredentials.token, appId: botCredentials.appId }
144
+ }
145
+
146
+ function isThreadChannelType(type: number): boolean {
147
+ return [
148
+ ChannelType.PublicThread,
149
+ ChannelType.PrivateThread,
150
+ ChannelType.AnnouncementThread,
151
+ ].includes(type)
152
+ }
153
+
154
+ async function sendDiscordMessageWithOptionalAttachment({
155
+ channelId,
156
+ prompt,
157
+ botToken,
158
+ embeds,
159
+ rest,
160
+ }: {
161
+ channelId: string
162
+ prompt: string
163
+ botToken: string
164
+ embeds?: Array<{ color: number; footer: { text: string } }>
165
+ rest: REST
166
+ }): Promise<{ id: string }> {
167
+ const discordMaxLength = 2000
168
+ const apiV10BaseUrl = getDiscordApiV10BaseUrl()
169
+ if (prompt.length <= discordMaxLength) {
170
+ return (await rest.post(Routes.channelMessages(channelId), {
171
+ body: { content: prompt, embeds },
172
+ })) as { id: string }
173
+ }
174
+
175
+ const preview = prompt.slice(0, 100).replace(/\n/g, ' ')
176
+ const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`
177
+
178
+ const tmpDir = path.join(process.cwd(), 'tmp')
179
+ if (!fs.existsSync(tmpDir)) {
180
+ fs.mkdirSync(tmpDir, { recursive: true })
181
+ }
182
+ const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`)
183
+ fs.writeFileSync(tmpFile, prompt)
184
+
185
+ try {
186
+ const formData = new FormData()
187
+ formData.append(
188
+ 'payload_json',
189
+ JSON.stringify({
190
+ content: summaryContent,
191
+ attachments: [{ id: 0, filename: 'prompt.md' }],
192
+ embeds,
193
+ }),
194
+ )
195
+ const buffer = fs.readFileSync(tmpFile)
196
+ formData.append(
197
+ 'files[0]',
198
+ new Blob([buffer], { type: 'text/markdown' }),
199
+ 'prompt.md',
200
+ )
201
+
202
+ const starterMessageResponse = await fetch(
203
+ `${apiV10BaseUrl}/channels/${channelId}/messages`,
204
+ {
205
+ method: 'POST',
206
+ headers: {
207
+ Authorization: `Bot ${botToken}`,
208
+ },
209
+ body: formData,
210
+ },
211
+ )
212
+
213
+ if (!starterMessageResponse.ok) {
214
+ const error = await starterMessageResponse.text()
215
+ throw new Error(
216
+ `Discord API error: ${starterMessageResponse.status} - ${error}`,
217
+ )
218
+ }
219
+
220
+ return (await starterMessageResponse.json()) as { id: string }
221
+ } finally {
222
+ fs.unlinkSync(tmpFile)
223
+ }
224
+ }
225
+
226
+ function formatRelativeTime(target: Date): string {
227
+ const diffMs = target.getTime() - Date.now()
228
+ if (diffMs <= 0) {
229
+ return 'due now'
230
+ }
231
+
232
+ const totalSeconds = Math.floor(diffMs / 1000)
233
+ if (totalSeconds < 60) {
234
+ return `${totalSeconds}s`
235
+ }
236
+
237
+ const totalMinutes = Math.floor(totalSeconds / 60)
238
+ if (totalMinutes < 60) {
239
+ return `${totalMinutes}m`
240
+ }
241
+
242
+ const hours = Math.floor(totalMinutes / 60)
243
+ const minutes = totalMinutes % 60
244
+ if (hours < 24) {
245
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`
246
+ }
247
+
248
+ const days = Math.floor(hours / 24)
249
+ const remainingHours = hours % 24
250
+ return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`
251
+ }
252
+
253
+ function formatTaskScheduleLine(schedule: ParsedSendAt): string {
254
+ if (schedule.scheduleKind === 'at') {
255
+ return `one-time at ${schedule.runAt.toISOString()}`
256
+ }
257
+ return `cron "${schedule.cronExpr}" (${schedule.timezone}) next ${schedule.nextRunAt.toISOString()}`
258
+ }
259
+
260
+ const EXIT_NO_RESTART = 64
261
+
262
+ // Detect if a CLI tool is installed, prompt to install if missing.
263
+ // Uses official install scripts with platform-specific commands for Unix vs Windows.
264
+ // Sets process.env[envPathKey] to the found binary path for the current session.
265
+ // After install, re-checks PATH first, then falls back to common install locations.
266
+ async function ensureCommandAvailable({
267
+ name,
268
+ envPathKey,
269
+ installUnix,
270
+ installWindows,
271
+ possiblePathsUnix,
272
+ possiblePathsWindows,
273
+ }: {
274
+ name: string
275
+ envPathKey: string
276
+ installUnix: string
277
+ installWindows: string
278
+ possiblePathsUnix: string[]
279
+ possiblePathsWindows: string[]
280
+ }): Promise<void> {
281
+ if (process.env[envPathKey]) {
282
+ return
283
+ }
284
+
285
+ const isWindows = process.platform === 'win32'
286
+ const whichCmd = isWindows ? 'where' : 'which'
287
+ const isInstalled = await execAsync(`${whichCmd} ${name}`, {
288
+ env: process.env,
289
+ }).then(
290
+ () => {
291
+ return true
292
+ },
293
+ () => {
294
+ return false
295
+ },
296
+ )
297
+
298
+ if (isInstalled) {
299
+ return
300
+ }
301
+
302
+ note(`${name} is required but not found in your PATH.`, `${name} Not Found`)
303
+
304
+ const shouldInstall = await confirm({
305
+ message: `Would you like to install ${name} right now?`,
306
+ })
307
+
308
+ if (isCancel(shouldInstall) || !shouldInstall) {
309
+ cancel(`${name} is required to run this bot`)
310
+ process.exit(EXIT_NO_RESTART)
311
+ }
312
+
313
+ cliLogger.log(`Installing ${name}...`)
314
+
315
+ try {
316
+ // Use explicit shell invocation to avoid Node shell-mode quirks on Windows.
317
+ // PowerShell needs -NoProfile and -ExecutionPolicy Bypass for install scripts.
318
+ // Unix uses login shell (-l) so install scripts can update PATH in shell config.
319
+ const cmd = isWindows ? 'powershell.exe' : '/bin/bash'
320
+ const args = isWindows
321
+ ? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', installWindows]
322
+ : ['-lc', installUnix]
323
+ await new Promise<void>((resolve, reject) => {
324
+ const child = spawn(cmd, args, { stdio: 'inherit', env: process.env })
325
+ child.on('close', (code) => {
326
+ if (code === 0) {
327
+ resolve()
328
+ } else {
329
+ reject(new Error(`${name} install exited with code ${code}`))
330
+ }
331
+ })
332
+ child.on('error', reject)
333
+ })
334
+ cliLogger.log(`${name} installed successfully!`)
335
+ } catch (error) {
336
+ cliLogger.log(`Failed to install ${name}`)
337
+ cliLogger.error(
338
+ 'Installation error:',
339
+ error instanceof Error ? error.message : String(error),
340
+ )
341
+ process.exit(EXIT_NO_RESTART)
342
+ }
343
+
344
+ // After install, re-check PATH first (install script may have added it)
345
+ const foundInPath = await execAsync(`${whichCmd} ${name}`, {
346
+ env: process.env,
347
+ }).then(
348
+ (result) => {
349
+ return result.stdout.trim()
350
+ },
351
+ () => {
352
+ return ''
353
+ },
354
+ )
355
+ if (foundInPath) {
356
+ process.env[envPathKey] = foundInPath
357
+ return
358
+ }
359
+
360
+ // Fall back to probing common install locations
361
+ const home = process.env.HOME || process.env.USERPROFILE || ''
362
+ const accessFlag = isWindows ? fs.constants.F_OK : fs.constants.X_OK
363
+ const possiblePaths = (isWindows ? possiblePathsWindows : possiblePathsUnix)
364
+ .filter((p) => {
365
+ return !p.startsWith('~') || home
366
+ })
367
+ .map((p) => {
368
+ return p.replace('~', home)
369
+ })
370
+
371
+ const installedPath = possiblePaths.find((p) => {
372
+ try {
373
+ fs.accessSync(p, accessFlag)
374
+ return true
375
+ } catch {
376
+ return false
377
+ }
378
+ })
379
+
380
+ if (!installedPath) {
381
+ note(
382
+ `${name} was installed but may not be available in this session.\n` +
383
+ 'Please restart your terminal and run this command again.',
384
+ 'Restart Required',
385
+ )
386
+ process.exit(EXIT_NO_RESTART)
387
+ }
388
+
389
+ process.env[envPathKey] = installedPath
390
+ }
391
+
392
+ // Run opencode upgrade in the background so the user always has the latest version.
393
+
394
+ // Spawn caffeinate on macOS to prevent system sleep while bot is running.
395
+ // Not detached, so it dies automatically with the parent process.
396
+ function startCaffeinate() {
397
+ if (process.platform !== 'darwin') {
398
+ return
399
+ }
400
+ try {
401
+ const proc = spawn('caffeinate', ['-i'], {
402
+ stdio: 'ignore',
403
+ detached: false,
404
+ })
405
+ proc.on('error', (err) => {
406
+ cliLogger.warn('Failed to start caffeinate:', err.message)
407
+ })
408
+ cliLogger.log('Started caffeinate to prevent system sleep')
409
+ } catch (err) {
410
+ cliLogger.warn(
411
+ 'Failed to spawn caffeinate:',
412
+ err instanceof Error ? err.message : String(err),
413
+ )
414
+ }
415
+ }
416
+ const cli = goke('kimaki')
417
+
418
+ process.title = 'kimaki'
419
+
420
+ type CliOptions = {
421
+ restart?: boolean
422
+ addChannels?: boolean
423
+ dataDir?: string
424
+ useWorktrees?: boolean
425
+ enableVoiceChannels?: boolean
426
+ }
427
+
428
+ // Commands to skip when registering user commands (reserved names)
429
+ const SKIP_USER_COMMANDS = ['init']
430
+
431
+ import { registeredUserCommands } from './config.js'
432
+
433
+ type AgentInfo = {
434
+ name: string
435
+ description?: string
436
+ mode: string
437
+ hidden?: boolean
438
+ }
439
+
440
+ async function registerCommands({
441
+ token,
442
+ appId,
443
+ userCommands = [],
444
+ agents = [],
445
+ }: {
446
+ token: string
447
+ appId: string
448
+ userCommands?: OpencodeCommand[]
449
+ agents?: AgentInfo[]
450
+ }) {
451
+ const commands = [
452
+ new SlashCommandBuilder()
453
+ .setName('resume')
454
+ .setDescription('Resume an existing OpenCode session')
455
+ .addStringOption((option) => {
456
+ option
457
+ .setName('session')
458
+ .setDescription('The session to resume')
459
+ .setRequired(true)
460
+ .setAutocomplete(true)
461
+
462
+ return option
463
+ })
464
+ .setDMPermission(false)
465
+ .toJSON(),
466
+ new SlashCommandBuilder()
467
+ .setName('new-session')
468
+ .setDescription('Start a new OpenCode session')
469
+ .addStringOption((option) => {
470
+ option
471
+ .setName('prompt')
472
+ .setDescription('Prompt content for the session')
473
+ .setRequired(true)
474
+
475
+ return option
476
+ })
477
+ .addStringOption((option) => {
478
+ option
479
+ .setName('files')
480
+ .setDescription(
481
+ 'Files to mention (comma or space separated; autocomplete)',
482
+ )
483
+ .setAutocomplete(true)
484
+ .setMaxLength(6000)
485
+
486
+ return option
487
+ })
488
+ .addStringOption((option) => {
489
+ option
490
+ .setName('agent')
491
+ .setDescription('Agent to use for this session')
492
+ .setAutocomplete(true)
493
+
494
+ return option
495
+ })
496
+ .setDMPermission(false)
497
+ .toJSON(),
498
+ new SlashCommandBuilder()
499
+ .setName('new-worktree')
500
+ .setDescription(
501
+ 'Create a new git worktree (in thread: uses thread name if no name given)',
502
+ )
503
+ .addStringOption((option) => {
504
+ option
505
+ .setName('name')
506
+ .setDescription(
507
+ 'Name for worktree (optional in threads - uses thread name)',
508
+ )
509
+ .setRequired(false)
510
+
511
+ return option
512
+ })
513
+ .setDMPermission(false)
514
+ .toJSON(),
515
+ new SlashCommandBuilder()
516
+ .setName('merge-worktree')
517
+ .setDescription('Merge the worktree branch into the default branch')
518
+ .setDMPermission(false)
519
+ .toJSON(),
520
+ new SlashCommandBuilder()
521
+ .setName('toggle-worktrees')
522
+ .setDescription(
523
+ 'Toggle automatic git worktree creation for new sessions in this channel',
524
+ )
525
+ .setDMPermission(false)
526
+ .toJSON(),
527
+ new SlashCommandBuilder()
528
+ .setName('toggle-mention-mode')
529
+ .setDescription(
530
+ 'Toggle mention-only mode (bot only responds when @mentioned)',
531
+ )
532
+ .setDMPermission(false)
533
+ .toJSON(),
534
+ new SlashCommandBuilder()
535
+ .setName('add-project')
536
+ .setDescription(
537
+ 'Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects',
538
+ )
539
+ .addStringOption((option) => {
540
+ option
541
+ .setName('project')
542
+ .setDescription(
543
+ 'Recent OpenCode projects. Use `npx kimaki project add` if not listed',
544
+ )
545
+ .setRequired(true)
546
+ .setAutocomplete(true)
547
+
548
+ return option
549
+ })
550
+ .setDMPermission(false)
551
+ .toJSON(),
552
+ new SlashCommandBuilder()
553
+ .setName('remove-project')
554
+ .setDescription('Remove Discord channels for a project')
555
+ .addStringOption((option) => {
556
+ option
557
+ .setName('project')
558
+ .setDescription('Select a project to remove')
559
+ .setRequired(true)
560
+ .setAutocomplete(true)
561
+
562
+ return option
563
+ })
564
+ .setDMPermission(false)
565
+ .toJSON(),
566
+ new SlashCommandBuilder()
567
+ .setName('create-new-project')
568
+ .setDescription(
569
+ 'Create a new project folder, initialize git, and start a session',
570
+ )
571
+ .addStringOption((option) => {
572
+ option
573
+ .setName('name')
574
+ .setDescription('Name for the new project folder')
575
+ .setRequired(true)
576
+
577
+ return option
578
+ })
579
+ .setDMPermission(false)
580
+ .toJSON(),
581
+ new SlashCommandBuilder()
582
+ .setName('abort')
583
+ .setDescription('Abort the current OpenCode request in this thread')
584
+ .setDMPermission(false)
585
+ .toJSON(),
586
+ new SlashCommandBuilder()
587
+ .setName('compact')
588
+ .setDescription(
589
+ 'Compact the session context by summarizing conversation history',
590
+ )
591
+ .setDMPermission(false)
592
+ .toJSON(),
593
+ new SlashCommandBuilder()
594
+ .setName('stop')
595
+ .setDescription('Abort the current OpenCode request in this thread')
596
+ .setDMPermission(false)
597
+ .toJSON(),
598
+ new SlashCommandBuilder()
599
+ .setName('share')
600
+ .setDescription('Share the current session as a public URL')
601
+ .setDMPermission(false)
602
+ .toJSON(),
603
+ new SlashCommandBuilder()
604
+ .setName('diff')
605
+ .setDescription('Show git diff as a shareable URL')
606
+ .setDMPermission(false)
607
+ .toJSON(),
608
+ new SlashCommandBuilder()
609
+ .setName('fork')
610
+ .setDescription('Fork the session from a past user message')
611
+ .setDMPermission(false)
612
+ .toJSON(),
613
+ new SlashCommandBuilder()
614
+ .setName('model')
615
+ .setDescription('Set the preferred model for this channel or session')
616
+ .setDMPermission(false)
617
+ .toJSON(),
618
+ new SlashCommandBuilder()
619
+ .setName('unset-model-override')
620
+ .setDescription('Remove model override and use default instead')
621
+ .setDMPermission(false)
622
+ .toJSON(),
623
+ new SlashCommandBuilder()
624
+ .setName('login')
625
+ .setDescription(
626
+ 'Authenticate with an AI provider (OAuth or API key). Use this instead of /connect',
627
+ )
628
+ .setDMPermission(false)
629
+ .toJSON(),
630
+ new SlashCommandBuilder()
631
+ .setName('agent')
632
+ .setDescription('Set the preferred agent for this channel or session')
633
+ .setDMPermission(false)
634
+ .toJSON(),
635
+ new SlashCommandBuilder()
636
+ .setName('queue')
637
+ .setDescription(
638
+ 'Queue a message to be sent after the current response finishes',
639
+ )
640
+ .addStringOption((option) => {
641
+ option
642
+ .setName('message')
643
+ .setDescription('The message to queue')
644
+ .setRequired(true)
645
+
646
+ return option
647
+ })
648
+ .setDMPermission(false)
649
+ .toJSON(),
650
+ new SlashCommandBuilder()
651
+ .setName('clear-queue')
652
+ .setDescription('Clear all queued messages in this thread')
653
+ .setDMPermission(false)
654
+ .toJSON(),
655
+ new SlashCommandBuilder()
656
+ .setName('queue-command')
657
+ .setDescription(
658
+ 'Queue a user command to run after the current response finishes',
659
+ )
660
+ .addStringOption((option) => {
661
+ option
662
+ .setName('command')
663
+ .setDescription('The command to run')
664
+ .setRequired(true)
665
+ .setAutocomplete(true)
666
+ return option
667
+ })
668
+ .addStringOption((option) => {
669
+ option
670
+ .setName('arguments')
671
+ .setDescription('Arguments to pass to the command')
672
+ .setRequired(false)
673
+ return option
674
+ })
675
+ .setDMPermission(false)
676
+ .toJSON(),
677
+ new SlashCommandBuilder()
678
+ .setName('undo')
679
+ .setDescription('Undo the last assistant message (revert file changes)')
680
+ .setDMPermission(false)
681
+ .toJSON(),
682
+ new SlashCommandBuilder()
683
+ .setName('redo')
684
+ .setDescription('Redo previously undone changes')
685
+ .setDMPermission(false)
686
+ .toJSON(),
687
+ new SlashCommandBuilder()
688
+ .setName('verbosity')
689
+ .setDescription('Set output verbosity for new sessions in this channel')
690
+ .addStringOption((option) => {
691
+ option
692
+ .setName('level')
693
+ .setDescription('Verbosity level')
694
+ .setRequired(true)
695
+ .addChoices(
696
+ { name: 'tools-and-text (default)', value: 'tools-and-text' },
697
+ {
698
+ name: 'text-and-essential-tools',
699
+ value: 'text-and-essential-tools',
700
+ },
701
+ { name: 'text-only', value: 'text-only' },
702
+ )
703
+ return option
704
+ })
705
+ .setDMPermission(false)
706
+ .toJSON(),
707
+ new SlashCommandBuilder()
708
+ .setName('restart-opencode-server')
709
+ .setDescription(
710
+ 'Restart the opencode server for this channel only (fixes state/auth/plugins)',
711
+ )
712
+ .setDMPermission(false)
713
+ .toJSON(),
714
+ new SlashCommandBuilder()
715
+ .setName('run-shell-command')
716
+ .setDescription(
717
+ 'Run a shell command in the project directory. Tip: prefix messages with ! as shortcut',
718
+ )
719
+ .addStringOption((option) => {
720
+ option
721
+ .setName('command')
722
+ .setDescription('Command to run')
723
+ .setRequired(true)
724
+ return option
725
+ })
726
+ .setDMPermission(false)
727
+ .toJSON(),
728
+ new SlashCommandBuilder()
729
+ .setName('context-usage')
730
+ .setDescription(
731
+ 'Show token usage and context window percentage for this session',
732
+ )
733
+ .setDMPermission(false)
734
+ .toJSON(),
735
+ new SlashCommandBuilder()
736
+ .setName('session-id')
737
+ .setDescription(
738
+ 'Show current session ID and opencode attach command for this thread',
739
+ )
740
+ .setDMPermission(false)
741
+ .toJSON(),
742
+ new SlashCommandBuilder()
743
+ .setName('upgrade-and-restart')
744
+ .setDescription(
745
+ 'Upgrade kimaki to the latest version and restart the bot',
746
+ )
747
+ .setDMPermission(false)
748
+ .toJSON(),
749
+ new SlashCommandBuilder()
750
+ .setName('transcription-key')
751
+ .setDescription(
752
+ 'Set API key for voice message transcription (OpenAI or Gemini)',
753
+ )
754
+ .setDMPermission(false)
755
+ .toJSON(),
756
+ ]
757
+
758
+ // Add user-defined commands with -cmd suffix
759
+ // Also populate registeredUserCommands for /queue-command autocomplete
760
+ registeredUserCommands.length = 0
761
+ for (const cmd of userCommands) {
762
+ if (SKIP_USER_COMMANDS.includes(cmd.name)) {
763
+ continue
764
+ }
765
+
766
+ // Sanitize command name: oh-my-opencode uses MCP commands with colons and slashes,
767
+ // which Discord doesn't allow in command names.
768
+ // Discord command names: lowercase, alphanumeric and hyphens only, must start with letter/number.
769
+ const sanitizedName = cmd.name
770
+ .toLowerCase()
771
+ .replace(/[:/]/g, '-') // Replace : and / with hyphens first
772
+ .replace(/[^a-z0-9-]/g, '-') // Replace any other non-alphanumeric chars
773
+ .replace(/-+/g, '-') // Collapse multiple hyphens
774
+ .replace(/^-|-$/g, '') // Remove leading/trailing hyphens
775
+
776
+ // Skip if sanitized name is empty - would create invalid command name like "-cmd"
777
+ if (!sanitizedName) {
778
+ continue
779
+ }
780
+
781
+ // Truncate base name before appending suffix so the -cmd suffix is never
782
+ // lost to Discord's 32-char command name limit.
783
+ const cmdSuffix = '-cmd'
784
+ const baseName = sanitizedName.slice(0, 32 - cmdSuffix.length)
785
+ const commandName = `${baseName}${cmdSuffix}`
786
+ const description = cmd.description || `Run /${cmd.name} command`
787
+
788
+ registeredUserCommands.push({
789
+ name: cmd.name,
790
+ discordName: baseName,
791
+ description,
792
+ })
793
+
794
+ commands.push(
795
+ new SlashCommandBuilder()
796
+ .setName(commandName)
797
+ .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
798
+ .addStringOption((option) => {
799
+ option
800
+ .setName('arguments')
801
+ .setDescription('Arguments to pass to the command')
802
+ .setRequired(false)
803
+ return option
804
+ })
805
+ .setDMPermission(false)
806
+ .toJSON(),
807
+ )
808
+ }
809
+
810
+ // Add agent-specific quick commands like /plan-agent, /build-agent
811
+ // Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
812
+ const primaryAgents = agents.filter(
813
+ (a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden,
814
+ )
815
+ for (const agent of primaryAgents) {
816
+ const sanitizedName = sanitizeAgentName(agent.name)
817
+ // Skip if sanitized name is empty or would create invalid command name
818
+ // Discord command names must start with a lowercase letter or number
819
+ if (!sanitizedName || !/^[a-z0-9]/.test(sanitizedName)) {
820
+ continue
821
+ }
822
+ // Truncate base name before appending suffix so the -agent suffix is never
823
+ // lost to Discord's 32-char command name limit.
824
+ const agentSuffix = '-agent'
825
+ const agentBaseName = sanitizedName.slice(0, 32 - agentSuffix.length)
826
+ const commandName = `${agentBaseName}${agentSuffix}`
827
+ const description = agent.description || `Switch to ${agent.name} agent`
828
+
829
+ commands.push(
830
+ new SlashCommandBuilder()
831
+ .setName(commandName)
832
+ .setDescription(description.slice(0, 100))
833
+ .setDMPermission(false)
834
+ .toJSON(),
835
+ )
836
+ }
837
+
838
+ const rest = createDiscordRest(token)
839
+
840
+ try {
841
+ const data = (await rest.put(Routes.applicationCommands(appId), {
842
+ body: commands,
843
+ })) as any[]
844
+
845
+ cliLogger.info(
846
+ `COMMANDS: Successfully registered ${data.length} slash commands`,
847
+ )
848
+ } catch (error) {
849
+ cliLogger.error(
850
+ 'COMMANDS: Failed to register slash commands: ' + String(error),
851
+ )
852
+ throw error
853
+ }
854
+ }
855
+
856
+ async function reconcileKimakiRole({ guild }: { guild: Guild }): Promise<void> {
857
+ try {
858
+ const roles = await guild.roles.fetch()
859
+ const existingRole = roles.find(
860
+ (role) => role.name.toLowerCase() === 'kimaki',
861
+ )
862
+
863
+ if (existingRole) {
864
+ if (existingRole.position > 1) {
865
+ await existingRole.setPosition(1)
866
+ cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`)
867
+ }
868
+ return
869
+ }
870
+
871
+ await guild.roles.create({
872
+ name: 'Kimaki',
873
+ position: 1,
874
+ reason:
875
+ 'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
876
+ })
877
+ cliLogger.info(`Created "Kimaki" role in ${guild.name}`)
878
+ } catch (error) {
879
+ cliLogger.warn(
880
+ `Could not reconcile Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`,
881
+ )
882
+ }
883
+ }
884
+
885
+ async function collectKimakiChannels({
886
+ guilds,
887
+ appId,
888
+ reconcileRoles,
889
+ }: {
890
+ guilds: Guild[]
891
+ appId: string
892
+ reconcileRoles: boolean
893
+ }): Promise<{ guild: Guild; channels: ChannelWithTags[] }[]> {
894
+ const guildResults = await Promise.all(
895
+ guilds.map(async (guild) => {
896
+ if (reconcileRoles) {
897
+ void reconcileKimakiRole({ guild })
898
+ }
899
+
900
+ const channels = await getChannelsWithDescriptions(guild)
901
+ const kimakiChans = channels.filter(
902
+ (ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
903
+ )
904
+
905
+ return { guild, channels: kimakiChans }
906
+ }),
907
+ )
908
+
909
+ return guildResults.filter((result) => {
910
+ return result.channels.length > 0
911
+ })
912
+ }
913
+
914
+ /**
915
+ * Store channel-directory mappings in the database.
916
+ * Called after Discord login to persist channel configurations.
917
+ */
918
+ async function storeChannelDirectories({
919
+ kimakiChannels,
920
+ }: {
921
+ kimakiChannels: { guild: Guild; channels: ChannelWithTags[] }[]
922
+ }): Promise<void> {
923
+ for (const { guild, channels } of kimakiChannels) {
924
+ for (const channel of channels) {
925
+ if (channel.kimakiDirectory) {
926
+ await setChannelDirectory({
927
+ channelId: channel.id,
928
+ directory: channel.kimakiDirectory,
929
+ channelType: 'text',
930
+ appId: channel.kimakiApp || null,
931
+ skipIfExists: true,
932
+ })
933
+
934
+ const voiceChannel = guild.channels.cache.find(
935
+ (ch) =>
936
+ ch.type === ChannelType.GuildVoice && ch.name === channel.name,
937
+ )
938
+
939
+ if (voiceChannel) {
940
+ await setChannelDirectory({
941
+ channelId: voiceChannel.id,
942
+ directory: channel.kimakiDirectory,
943
+ channelType: 'voice',
944
+ appId: channel.kimakiApp || null,
945
+ skipIfExists: true,
946
+ })
947
+ }
948
+ }
949
+ }
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Show the ready message with channel links.
955
+ * Called at the end of startup to display available channels.
956
+ */
957
+ function showReadyMessage({
958
+ kimakiChannels,
959
+ createdChannels,
960
+ appId,
961
+ }: {
962
+ kimakiChannels: { guild: Guild; channels: ChannelWithTags[] }[]
963
+ createdChannels: { name: string; id: string; guildId: string }[]
964
+ appId: string
965
+ }): void {
966
+ const allChannels: {
967
+ name: string
968
+ id: string
969
+ guildId: string
970
+ directory?: string
971
+ }[] = []
972
+
973
+ allChannels.push(...createdChannels)
974
+
975
+ kimakiChannels.forEach(({ guild, channels }) => {
976
+ channels.forEach((ch) => {
977
+ allChannels.push({
978
+ name: ch.name,
979
+ id: ch.id,
980
+ guildId: guild.id,
981
+ directory: ch.kimakiDirectory,
982
+ })
983
+ })
984
+ })
985
+
986
+ if (allChannels.length > 0) {
987
+ const channelLinks = allChannels
988
+ .map(
989
+ (ch) =>
990
+ `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`,
991
+ )
992
+ .join('\n')
993
+
994
+ note(
995
+ `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!`,
996
+ '🚀 Ready to Use',
997
+ )
998
+ }
999
+
1000
+ note(
1001
+ '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.',
1002
+ '⚠️ Keep Running',
1003
+ )
1004
+ }
1005
+
1006
+ /**
1007
+ * Background initialization for quick start mode.
1008
+ * Starts OpenCode server and registers slash commands without blocking bot startup.
1009
+ */
1010
+ async function backgroundInit({
1011
+ currentDir,
1012
+ token,
1013
+ appId,
1014
+ }: {
1015
+ currentDir: string
1016
+ token: string
1017
+ appId: string
1018
+ }): Promise<void> {
1019
+ try {
1020
+ const opencodeResult = await initializeOpencodeForDirectory(currentDir)
1021
+ if (opencodeResult instanceof Error) {
1022
+ cliLogger.warn('Background OpenCode init failed:', opencodeResult.message)
1023
+ // Still try to register basic commands without user commands/agents
1024
+ await registerCommands({ token, appId, userCommands: [], agents: [] })
1025
+ return
1026
+ }
1027
+
1028
+ const getClient = opencodeResult
1029
+
1030
+ const [userCommands, agents] = await Promise.all([
1031
+ getClient()
1032
+ .command.list({ directory: currentDir })
1033
+ .then((r) => r.data || [])
1034
+ .catch((error) => {
1035
+ cliLogger.warn(
1036
+ 'Failed to load user commands during background init:',
1037
+ error instanceof Error ? error.message : String(error),
1038
+ )
1039
+ return []
1040
+ }),
1041
+ getClient()
1042
+ .app.agents({ directory: currentDir })
1043
+ .then((r) => r.data || [])
1044
+ .catch((error) => {
1045
+ cliLogger.warn(
1046
+ 'Failed to load agents during background init:',
1047
+ error instanceof Error ? error.message : String(error),
1048
+ )
1049
+ return []
1050
+ }),
1051
+ ])
1052
+
1053
+ await registerCommands({ token, appId, userCommands, agents })
1054
+ cliLogger.log('Slash commands registered!')
1055
+ } catch (error) {
1056
+ cliLogger.error(
1057
+ 'Background init failed:',
1058
+ error instanceof Error ? error.message : String(error),
1059
+ )
1060
+ void notifyError(error, 'Background init failed')
1061
+ }
1062
+ }
1063
+
1064
+ async function run({
1065
+ restart,
1066
+ addChannels,
1067
+ useWorktrees,
1068
+ enableVoiceChannels,
1069
+ }: CliOptions) {
1070
+ startCaffeinate()
1071
+
1072
+ const forceSetup = Boolean(restart)
1073
+
1074
+ // Step 0: Ensure required CLI tools are installed (OpenCode + Bun)
1075
+ await ensureCommandAvailable({
1076
+ name: 'opencode',
1077
+ envPathKey: 'OPENCODE_PATH',
1078
+ installUnix: 'curl -fsSL https://opencode.ai/install | bash',
1079
+ installWindows: 'irm https://opencode.ai/install.ps1 | iex',
1080
+ possiblePathsUnix: [
1081
+ '~/.local/bin/opencode',
1082
+ '~/.opencode/bin/opencode',
1083
+ '/usr/local/bin/opencode',
1084
+ '/opt/opencode/bin/opencode',
1085
+ ],
1086
+ possiblePathsWindows: [
1087
+ '~\\.local\\bin\\opencode.exe',
1088
+ '~\\AppData\\Local\\opencode\\opencode.exe',
1089
+ '~\\.opencode\\bin\\opencode.exe',
1090
+ ],
1091
+ })
1092
+
1093
+ await ensureCommandAvailable({
1094
+ name: 'bun',
1095
+ envPathKey: 'BUN_PATH',
1096
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
1097
+ installWindows: 'irm bun.sh/install.ps1 | iex',
1098
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1099
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1100
+ })
1101
+
1102
+
1103
+ backgroundUpgradeKimaki()
1104
+
1105
+ // Start in-process Hrana server before database init. Required for the bot
1106
+ // process because it serves as both the DB server and the single-instance
1107
+ // lock (binds the fixed lock port). Without it, IPC and lock enforcement
1108
+ // don't work. CLI subcommands skip the server and use file: directly.
1109
+ const hranaResult = await startHranaServer({
1110
+ dbPath: path.join(getDataDir(), 'discord-sessions.db'),
1111
+ })
1112
+ if (hranaResult instanceof Error) {
1113
+ cliLogger.error('Failed to start hrana server:', hranaResult.message)
1114
+ process.exit(EXIT_NO_RESTART)
1115
+ }
1116
+
1117
+ // Initialize database (connects to hrana server via HTTP)
1118
+ await initDatabase()
1119
+
1120
+ // Resolve bot credentials from (in priority order):
1121
+ // 1. KIMAKI_BOT_TOKEN env var (headless/CI deployments)
1122
+ // 2. Saved credentials in the database
1123
+ // 3. Interactive setup wizard (first-time users)
1124
+ // App ID is always derived from the token (base64 first segment).
1125
+ const { appId, token, isQuickStart } = await (async (): Promise<{
1126
+ appId: string
1127
+ token: string
1128
+ isQuickStart: boolean
1129
+ }> => {
1130
+ const envBot = getBotToken({ allowDatabase: false })
1131
+ const existingBot = getBotToken({ preferEnv: false })
1132
+
1133
+ // 1. Env var takes precedence (headless deployments)
1134
+ if (envBot && !forceSetup) {
1135
+ const derivedAppId = appIdFromToken(envBot.token)
1136
+ if (!derivedAppId) {
1137
+ cliLogger.error(
1138
+ 'Could not derive Application ID from KIMAKI_BOT_TOKEN. The token appears malformed.',
1139
+ )
1140
+ process.exit(EXIT_NO_RESTART)
1141
+ }
1142
+ await setBotToken(derivedAppId, envBot.token)
1143
+ cliLogger.log(`Using KIMAKI_BOT_TOKEN env var (App ID: ${derivedAppId})`)
1144
+ return { appId: derivedAppId, token: envBot.token, isQuickStart: !addChannels }
1145
+ }
1146
+
1147
+ // 2. Saved credentials in the database
1148
+ if (existingBot && !forceSetup) {
1149
+ if (!existingBot.appId) {
1150
+ cliLogger.error(
1151
+ 'Saved bot token is missing an application ID. Re-run setup with `kimaki --restart`.',
1152
+ )
1153
+ process.exit(EXIT_NO_RESTART)
1154
+ }
1155
+ note(
1156
+ `Using saved bot credentials:\nApp ID: ${existingBot.appId}\n\nTo use different credentials, run with --restart`,
1157
+ 'Existing Bot Found',
1158
+ )
1159
+ note(
1160
+ `Bot install URL (in case you need to add it to another server):\n${generateBotInstallUrl({ clientId: existingBot.appId })}`,
1161
+ 'Install URL',
1162
+ )
1163
+ return {
1164
+ appId: existingBot.appId,
1165
+ token: existingBot.token,
1166
+ isQuickStart: !addChannels,
1167
+ }
1168
+ }
1169
+
1170
+ // 3. Interactive setup wizard
1171
+ if (forceSetup && existingBot?.appId) {
1172
+ note('Ignoring saved credentials due to --restart flag', 'Restart Setup')
1173
+ }
1174
+
1175
+ note(
1176
+ '1. Go to https://discord.com/developers/applications\n' +
1177
+ '2. Click "New Application"\n' +
1178
+ '3. Give your application a name',
1179
+ 'Step 1: Create Discord Application',
1180
+ )
1181
+
1182
+ note(
1183
+ '1. Go to the "Bot" section in the left sidebar\n' +
1184
+ '2. Scroll down to "Privileged Gateway Intents"\n' +
1185
+ '3. Enable these intents by toggling them ON:\n' +
1186
+ ' • SERVER MEMBERS INTENT\n' +
1187
+ ' • MESSAGE CONTENT INTENT\n' +
1188
+ '4. Click "Save Changes" at the bottom',
1189
+ 'Step 2: Enable Required Intents',
1190
+ )
1191
+
1192
+ const intentsConfirmed = await text({
1193
+ message: 'Press Enter after enabling both intents:',
1194
+ placeholder: 'Enter',
1195
+ })
1196
+ if (isCancel(intentsConfirmed)) {
1197
+ cancel('Setup cancelled')
1198
+ process.exit(0)
1199
+ }
1200
+
1201
+ note(
1202
+ '1. Still in the "Bot" section\n' +
1203
+ '2. Click "Reset Token" to generate a new bot token (in case of errors try again)\n' +
1204
+ "3. Copy the token (you won't be able to see it again!)",
1205
+ 'Step 3: Get Bot Token',
1206
+ )
1207
+ const tokenInput = await password({
1208
+ message:
1209
+ 'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
1210
+ validate(value) {
1211
+ const cleaned = stripBracketedPaste(value)
1212
+ if (!cleaned) {
1213
+ return 'Bot token is required'
1214
+ }
1215
+ if (cleaned.length < 50) {
1216
+ return 'Invalid token format (too short)'
1217
+ }
1218
+ },
1219
+ })
1220
+ if (isCancel(tokenInput)) {
1221
+ cancel('Setup cancelled')
1222
+ process.exit(0)
1223
+ }
1224
+
1225
+ const wizardToken = stripBracketedPaste(tokenInput)
1226
+ const derivedAppId = appIdFromToken(wizardToken)
1227
+ if (!derivedAppId) {
1228
+ cliLogger.error(
1229
+ 'Could not derive Application ID from the bot token. The token appears malformed.',
1230
+ )
1231
+ process.exit(EXIT_NO_RESTART)
1232
+ }
1233
+
1234
+ await setBotToken(derivedAppId, wizardToken)
1235
+
1236
+ note(
1237
+ `Bot install URL:\n${generateBotInstallUrl({ clientId: derivedAppId })}\n\nYou MUST install the bot in your Discord server before continuing.`,
1238
+ 'Step 4: Install Bot to Server',
1239
+ )
1240
+ const installed = await text({
1241
+ message: 'Press Enter AFTER you have installed the bot in your server:',
1242
+ placeholder: 'Enter',
1243
+ })
1244
+ if (isCancel(installed)) {
1245
+ cancel('Setup cancelled')
1246
+ process.exit(0)
1247
+ }
1248
+
1249
+ return { appId: derivedAppId, token: wizardToken, isQuickStart: false }
1250
+ })()
1251
+
1252
+ const shouldAddChannels =
1253
+ !isQuickStart || forceSetup || Boolean(addChannels)
1254
+
1255
+ // Start OpenCode server EARLY - let it initialize in parallel with Discord login.
1256
+ // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
1257
+ const currentDir = process.cwd()
1258
+ cliLogger.log('Starting OpenCode server...')
1259
+ const opencodePromise = initializeOpencodeForDirectory(currentDir).then(
1260
+ (result) => {
1261
+ if (result instanceof Error) {
1262
+ throw new Error(result.message)
1263
+ }
1264
+ return result
1265
+ },
1266
+ )
1267
+
1268
+ cliLogger.log('Connecting to Discord...')
1269
+ const discordClient = await createDiscordClient()
1270
+
1271
+ const guilds: Guild[] = []
1272
+ const kimakiChannels: { guild: Guild; channels: ChannelWithTags[] }[] = []
1273
+ const createdChannels: { name: string; id: string; guildId: string }[] = []
1274
+
1275
+ try {
1276
+ await new Promise((resolve, reject) => {
1277
+ discordClient.once(Events.ClientReady, async (c) => {
1278
+ guilds.push(...Array.from(c.guilds.cache.values()))
1279
+
1280
+ if (isQuickStart) {
1281
+ resolve(null)
1282
+ return
1283
+ }
1284
+
1285
+ // Process guild metadata when setup flow needs channel prompts.
1286
+ const guildResults = await collectKimakiChannels({
1287
+ guilds,
1288
+ appId,
1289
+ reconcileRoles: true,
1290
+ })
1291
+
1292
+ // Collect results
1293
+ for (const result of guildResults) {
1294
+ kimakiChannels.push(result)
1295
+ }
1296
+
1297
+ resolve(null)
1298
+ })
1299
+
1300
+ discordClient.once(Events.Error, reject)
1301
+
1302
+ discordClient.login(token).catch(reject)
1303
+ })
1304
+
1305
+ cliLogger.log('Connected to Discord!')
1306
+ // Start IPC polling now that Discord client is ready.
1307
+ // Register cleanup on process exit since the shutdown handler lives in discord-bot.ts.
1308
+ await startIpcPolling({ discordClient })
1309
+ process.on('exit', stopIpcPolling)
1310
+ } catch (error) {
1311
+ cliLogger.log('Failed to connect to Discord')
1312
+ cliLogger.error(
1313
+ 'Error: ' + (error instanceof Error ? error.message : String(error)),
1314
+ )
1315
+ process.exit(EXIT_NO_RESTART)
1316
+ }
1317
+ await setBotToken(appId, token)
1318
+
1319
+ // Quick start: start the bot first, then defer channel sync/role reconciliation.
1320
+ if (isQuickStart) {
1321
+ cliLogger.log('Starting Discord bot...')
1322
+ await startDiscordBot({ token, appId, discordClient, useWorktrees })
1323
+ cliLogger.log('Discord bot is running!')
1324
+
1325
+ // Background channel sync + role reconciliation should never block ready state.
1326
+ void (async () => {
1327
+ try {
1328
+ const backgroundChannels = await collectKimakiChannels({
1329
+ guilds,
1330
+ appId,
1331
+ reconcileRoles: true,
1332
+ })
1333
+ await storeChannelDirectories({ kimakiChannels: backgroundChannels })
1334
+ cliLogger.log(
1335
+ `Background channel sync completed for ${backgroundChannels.length} guild(s)`,
1336
+ )
1337
+ } catch (error) {
1338
+ cliLogger.warn(
1339
+ 'Background channel sync failed:',
1340
+ error instanceof Error ? error.message : String(error),
1341
+ )
1342
+ }
1343
+ })()
1344
+
1345
+ // Background: OpenCode init + slash command registration (non-blocking)
1346
+ void backgroundInit({ currentDir, token, appId })
1347
+
1348
+ showReadyMessage({ kimakiChannels: [], createdChannels, appId })
1349
+ outro('✨ Bot ready! Listening for messages...')
1350
+ return
1351
+ }
1352
+
1353
+ // Store channel-directory mappings
1354
+ await storeChannelDirectories({ kimakiChannels })
1355
+
1356
+ if (kimakiChannels.length > 0) {
1357
+ const channelList = kimakiChannels
1358
+ .flatMap(({ guild, channels }) =>
1359
+ channels.map((ch) => {
1360
+ const appInfo =
1361
+ ch.kimakiApp === appId
1362
+ ? ' (this bot)'
1363
+ : ch.kimakiApp
1364
+ ? ` (app: ${ch.kimakiApp})`
1365
+ : ''
1366
+ return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`
1367
+ }),
1368
+ )
1369
+ .join('\n')
1370
+
1371
+ note(channelList, 'Existing Kimaki Channels')
1372
+ }
1373
+
1374
+ // Full setup path: wait for OpenCode, show prompts, create channels if needed
1375
+ // Await the OpenCode server that was started in parallel with Discord login
1376
+ cliLogger.log('Waiting for OpenCode server...')
1377
+ const getClient = await opencodePromise
1378
+ cliLogger.log('OpenCode server ready!')
1379
+
1380
+ cliLogger.log('Fetching OpenCode data...')
1381
+
1382
+ // Fetch projects, commands, and agents in parallel
1383
+ const [projects, allUserCommands, allAgents] = await Promise.all([
1384
+ getClient()
1385
+ .project.list()
1386
+ .then((r) => r.data || [])
1387
+ .catch((error) => {
1388
+ cliLogger.log('Failed to fetch projects')
1389
+ cliLogger.error(
1390
+ 'Error:',
1391
+ error instanceof Error ? error.message : String(error),
1392
+ )
1393
+ discordClient.destroy()
1394
+ process.exit(EXIT_NO_RESTART)
1395
+ }),
1396
+ getClient()
1397
+ .command.list({ directory: currentDir })
1398
+ .then((r) => r.data || [])
1399
+ .catch((error) => {
1400
+ cliLogger.warn(
1401
+ 'Failed to load user commands during setup:',
1402
+ error instanceof Error ? error.message : String(error),
1403
+ )
1404
+ return []
1405
+ }),
1406
+ getClient()
1407
+ .app.agents({ directory: currentDir })
1408
+ .then((r) => r.data || [])
1409
+ .catch((error) => {
1410
+ cliLogger.warn(
1411
+ 'Failed to load agents during setup:',
1412
+ error instanceof Error ? error.message : String(error),
1413
+ )
1414
+ return []
1415
+ }),
1416
+ ])
1417
+
1418
+ cliLogger.log(`Found ${projects.length} OpenCode project(s)`)
1419
+
1420
+ const existingDirs = kimakiChannels.flatMap(({ channels }) =>
1421
+ channels
1422
+ .filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
1423
+ .map((ch) => ch.kimakiDirectory)
1424
+ .filter(Boolean),
1425
+ )
1426
+
1427
+ const availableProjects = deduplicateByKey(
1428
+ projects.filter((project) => {
1429
+ if (existingDirs.includes(project.worktree)) {
1430
+ return false
1431
+ }
1432
+ if (path.basename(project.worktree).startsWith('opencode-test-')) {
1433
+ return false
1434
+ }
1435
+ return true
1436
+ }),
1437
+ (x) => x.worktree,
1438
+ )
1439
+
1440
+ if (availableProjects.length === 0) {
1441
+ note(
1442
+ 'All OpenCode projects already have Discord channels',
1443
+ 'No New Projects',
1444
+ )
1445
+ }
1446
+
1447
+ if (
1448
+ (!existingDirs?.length && availableProjects.length > 0) ||
1449
+ shouldAddChannels
1450
+ ) {
1451
+ const selectedProjects = await multiselect({
1452
+ message: 'Select projects to create Discord channels for:',
1453
+ options: availableProjects.map((project) => ({
1454
+ value: project.id,
1455
+ label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
1456
+ })),
1457
+ required: false,
1458
+ })
1459
+
1460
+ if (!isCancel(selectedProjects) && selectedProjects.length > 0) {
1461
+ let targetGuild: Guild
1462
+ if (guilds.length === 0) {
1463
+ cliLogger.error(
1464
+ 'No Discord servers found! The bot must be installed in at least one server.',
1465
+ )
1466
+ process.exit(EXIT_NO_RESTART)
1467
+ }
1468
+
1469
+ if (guilds.length === 1) {
1470
+ targetGuild = guilds[0]!
1471
+ note(`Using server: ${targetGuild.name}`, 'Server Selected')
1472
+ } else {
1473
+ const guildSelection = await multiselect({
1474
+ message: 'Select a Discord server to create channels in:',
1475
+ options: guilds.map((guild) => ({
1476
+ value: guild.id,
1477
+ label: `${guild.name} (${guild.memberCount} members)`,
1478
+ })),
1479
+ required: true,
1480
+ maxItems: 1,
1481
+ })
1482
+
1483
+ if (isCancel(guildSelection)) {
1484
+ cancel('Setup cancelled')
1485
+ process.exit(0)
1486
+ }
1487
+
1488
+ targetGuild = guilds.find((g) => g.id === guildSelection[0])!
1489
+ }
1490
+
1491
+ cliLogger.log('Creating Discord channels...')
1492
+
1493
+ for (const projectId of selectedProjects) {
1494
+ const project = projects.find((p) => p.id === projectId)
1495
+ if (!project) continue
1496
+
1497
+ try {
1498
+ const { textChannelId, channelName } = await createProjectChannels({
1499
+ guild: targetGuild,
1500
+ projectDirectory: project.worktree,
1501
+ appId,
1502
+ botName: discordClient.user?.username,
1503
+ enableVoiceChannels,
1504
+ })
1505
+
1506
+ createdChannels.push({
1507
+ name: channelName,
1508
+ id: textChannelId,
1509
+ guildId: targetGuild.id,
1510
+ })
1511
+ } catch (error) {
1512
+ cliLogger.error(
1513
+ `Failed to create channels for ${path.basename(project.worktree)}:`,
1514
+ error,
1515
+ )
1516
+ }
1517
+ }
1518
+
1519
+ cliLogger.log(`Created ${createdChannels.length} channel(s)`)
1520
+
1521
+ if (createdChannels.length > 0) {
1522
+ note(
1523
+ createdChannels.map((ch) => `#${ch.name}`).join('\n'),
1524
+ 'Created Channels',
1525
+ )
1526
+ }
1527
+ }
1528
+ }
1529
+
1530
+ // Log available user commands
1531
+ const registrableCommands = allUserCommands.filter(
1532
+ (cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
1533
+ )
1534
+
1535
+ if (registrableCommands.length > 0) {
1536
+ const commandList = registrableCommands
1537
+ .map(
1538
+ (cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`,
1539
+ )
1540
+ .join('\n')
1541
+
1542
+ note(
1543
+ `Found ${registrableCommands.length} user-defined command(s):\n${commandList}`,
1544
+ 'OpenCode Commands',
1545
+ )
1546
+ }
1547
+
1548
+ cliLogger.log('Registering slash commands asynchronously...')
1549
+ void registerCommands({
1550
+ token,
1551
+ appId,
1552
+ userCommands: allUserCommands,
1553
+ agents: allAgents,
1554
+ })
1555
+ .then(() => {
1556
+ cliLogger.log('Slash commands registered!')
1557
+ })
1558
+ .catch((error) => {
1559
+ cliLogger.error(
1560
+ 'Failed to register slash commands:',
1561
+ error instanceof Error ? error.message : String(error),
1562
+ )
1563
+ })
1564
+
1565
+ cliLogger.log('Starting Discord bot...')
1566
+ await startDiscordBot({ token, appId, discordClient, useWorktrees })
1567
+ cliLogger.log('Discord bot is running!')
1568
+
1569
+ showReadyMessage({ kimakiChannels, createdChannels, appId })
1570
+ outro(
1571
+ '✨ Setup complete! Listening for new messages... do not close this process.',
1572
+ )
1573
+ }
1574
+
1575
+ cli
1576
+ .command('', 'Set up and run the Kimaki Discord bot')
1577
+ .option('--restart', 'Prompt for new credentials even if saved')
1578
+ .option(
1579
+ '--add-channels',
1580
+ 'Select OpenCode projects to create Discord channels before starting',
1581
+ )
1582
+ .option(
1583
+ '--data-dir <path>',
1584
+ 'Data directory for config and database (default: ~/.kimaki)',
1585
+ )
1586
+ .option('--install-url', 'Print the bot install URL and exit')
1587
+ .option(
1588
+ '--use-worktrees',
1589
+ 'Create git worktrees for all new sessions started from channel messages',
1590
+ )
1591
+ .option(
1592
+ '--enable-voice-channels',
1593
+ 'Create voice channels for projects (disabled by default)',
1594
+ )
1595
+ .option(
1596
+ '--verbosity <level>',
1597
+ 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)',
1598
+ )
1599
+ .option(
1600
+ '--mention-mode',
1601
+ 'Bot only responds when @mentioned (default for all channels)',
1602
+ )
1603
+ .option(
1604
+ '--no-critique',
1605
+ 'Disable automatic diff upload to critique.work in system prompts',
1606
+ )
1607
+ .option(
1608
+ '--auto-restart',
1609
+ 'Automatically restart the bot on crash or OOM kill',
1610
+ )
1611
+ .option(
1612
+ '--verbose-opencode-server',
1613
+ 'Forward OpenCode server stdout/stderr to kimaki.log',
1614
+ )
1615
+ .option('--no-sentry', 'Disable Sentry error reporting')
1616
+ .action(
1617
+ async (options: {
1618
+ restart?: boolean
1619
+ addChannels?: boolean
1620
+ dataDir?: string
1621
+ installUrl?: boolean
1622
+ useWorktrees?: boolean
1623
+ enableVoiceChannels?: boolean
1624
+ verbosity?: string
1625
+ mentionMode?: boolean
1626
+ noCritique?: boolean
1627
+ autoRestart?: boolean
1628
+ verboseOpencodeServer?: boolean
1629
+ noSentry?: boolean
1630
+ }) => {
1631
+ try {
1632
+ // Set data directory early, before any database access
1633
+ if (options.dataDir) {
1634
+ setDataDir(options.dataDir)
1635
+ cliLogger.log(`Using data directory: ${getDataDir()}`)
1636
+ }
1637
+
1638
+ // Initialize file logging to <dataDir>/kimaki.log
1639
+ initLogFile(getDataDir())
1640
+
1641
+ if (options.verbosity) {
1642
+ const validLevels = [
1643
+ 'tools-and-text',
1644
+ 'text-and-essential-tools',
1645
+ 'text-only',
1646
+ ]
1647
+ if (!validLevels.includes(options.verbosity)) {
1648
+ cliLogger.error(
1649
+ `Invalid verbosity level: ${options.verbosity}. Use one of: ${validLevels.join(', ')}`,
1650
+ )
1651
+ process.exit(EXIT_NO_RESTART)
1652
+ }
1653
+ setDefaultVerbosity(
1654
+ options.verbosity as
1655
+ | 'tools-and-text'
1656
+ | 'text-and-essential-tools'
1657
+ | 'text-only',
1658
+ )
1659
+ cliLogger.log(`Default verbosity: ${options.verbosity}`)
1660
+ }
1661
+
1662
+ if (options.mentionMode) {
1663
+ setDefaultMentionMode(true)
1664
+ cliLogger.log(
1665
+ 'Default mention mode: enabled (bot only responds when @mentioned)',
1666
+ )
1667
+ }
1668
+
1669
+ if (options.noCritique) {
1670
+ setCritiqueEnabled(false)
1671
+ cliLogger.log(
1672
+ 'Critique disabled: diffs will not be auto-uploaded to critique.work',
1673
+ )
1674
+ }
1675
+
1676
+ if (options.verboseOpencodeServer) {
1677
+ setVerboseOpencodeServer(true)
1678
+ cliLogger.log(
1679
+ 'Verbose OpenCode server: stdout/stderr will be forwarded to kimaki.log',
1680
+ )
1681
+ }
1682
+
1683
+ if (options.noSentry) {
1684
+ process.env.KIMAKI_SENTRY_DISABLED = '1'
1685
+ cliLogger.log('Sentry error reporting disabled (--no-sentry)')
1686
+ } else {
1687
+ initSentry()
1688
+ }
1689
+
1690
+ if (options.installUrl) {
1691
+ await initDatabase()
1692
+ const existingBot = getBotToken({ preferEnv: false })
1693
+
1694
+ if (!existingBot || !existingBot.appId) {
1695
+ cliLogger.error(
1696
+ 'No bot configured yet. Run `kimaki` first to set up.',
1697
+ )
1698
+ process.exit(EXIT_NO_RESTART)
1699
+ }
1700
+
1701
+ cliLogger.log(generateBotInstallUrl({ clientId: existingBot.appId }))
1702
+ process.exit(0)
1703
+ }
1704
+
1705
+ // Single-instance enforcement is handled by the hrana server binding the lock port.
1706
+ // startHranaServer() in run() evicts any existing instance before binding.
1707
+ await run({
1708
+ restart: options.restart,
1709
+ addChannels: options.addChannels,
1710
+ dataDir: options.dataDir,
1711
+ useWorktrees: options.useWorktrees,
1712
+ enableVoiceChannels: options.enableVoiceChannels,
1713
+ })
1714
+ } catch (error) {
1715
+ cliLogger.error('Unhandled error:', formatErrorWithStack(error))
1716
+ process.exit(EXIT_NO_RESTART)
1717
+ }
1718
+ },
1719
+ )
1720
+
1721
+ cli
1722
+ .command(
1723
+ 'upload-to-discord [...files]',
1724
+ 'Upload files to a Discord thread for a session',
1725
+ )
1726
+ .option('-s, --session <sessionId>', 'OpenCode session ID')
1727
+ .action(async (files: string[], options: { session?: string }) => {
1728
+ try {
1729
+ const { session: sessionId } = options
1730
+
1731
+ if (!sessionId) {
1732
+ cliLogger.error('Session ID is required. Use --session <sessionId>')
1733
+ process.exit(EXIT_NO_RESTART)
1734
+ }
1735
+
1736
+ if (!files || files.length === 0) {
1737
+ cliLogger.error('At least one file path is required')
1738
+ process.exit(EXIT_NO_RESTART)
1739
+ }
1740
+
1741
+ const resolvedFiles = files.map((f) => path.resolve(f))
1742
+ for (const file of resolvedFiles) {
1743
+ if (!fs.existsSync(file)) {
1744
+ cliLogger.error(`File not found: ${file}`)
1745
+ process.exit(EXIT_NO_RESTART)
1746
+ }
1747
+ }
1748
+
1749
+ await initDatabase()
1750
+
1751
+ const threadId = await getThreadIdBySessionId(sessionId)
1752
+
1753
+ if (!threadId) {
1754
+ cliLogger.error(`No Discord thread found for session: ${sessionId}`)
1755
+ process.exit(EXIT_NO_RESTART)
1756
+ }
1757
+
1758
+ const botRow = getBotToken({ preferEnv: false })
1759
+
1760
+ if (!botRow) {
1761
+ cliLogger.error(
1762
+ 'No bot credentials found. Run `kimaki` first to set up the bot.',
1763
+ )
1764
+ process.exit(EXIT_NO_RESTART)
1765
+ }
1766
+
1767
+ cliLogger.log(`Uploading ${resolvedFiles.length} file(s)...`)
1768
+
1769
+ await uploadFilesToDiscord({
1770
+ threadId: threadId,
1771
+ botToken: botRow.token,
1772
+ files: resolvedFiles,
1773
+ })
1774
+
1775
+ cliLogger.log(`Uploaded ${resolvedFiles.length} file(s)!`)
1776
+
1777
+ note(
1778
+ `Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`,
1779
+ '✅ Success',
1780
+ )
1781
+
1782
+ process.exit(0)
1783
+ } catch (error) {
1784
+ cliLogger.error(
1785
+ 'Error:',
1786
+ error instanceof Error ? error.message : String(error),
1787
+ )
1788
+ process.exit(EXIT_NO_RESTART)
1789
+ }
1790
+ })
1791
+
1792
+ cli
1793
+ .command(
1794
+ 'send',
1795
+ 'Send a message to a Discord channel/thread. Default creates a thread; use --thread/--session to continue existing.',
1796
+ )
1797
+ .alias('start-session') // backwards compatibility
1798
+ .option('-c, --channel <channelId>', 'Discord channel ID')
1799
+ .option(
1800
+ '-d, --project <path>',
1801
+ 'Project directory (alternative to --channel)',
1802
+ )
1803
+ .option('-p, --prompt <prompt>', 'Message content')
1804
+ .option(
1805
+ '-n, --name [name]',
1806
+ 'Thread name (optional, defaults to prompt preview)',
1807
+ )
1808
+ .option(
1809
+ '-a, --app-id [appId]',
1810
+ 'Bot application ID (required if no local database)',
1811
+ )
1812
+ .option(
1813
+ '--notify-only',
1814
+ 'Create notification thread without starting AI session',
1815
+ )
1816
+ .option(
1817
+ '--worktree [name]',
1818
+ 'Create git worktree for session (name optional, derives from thread name)',
1819
+ )
1820
+ .option('-u, --user <username>', 'Discord username to add to thread')
1821
+ .option('--agent <agent>', 'Agent to use for the session')
1822
+ .option('--model <model>', 'Model to use (format: provider/model)')
1823
+ .option(
1824
+ '--send-at <schedule>',
1825
+ 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)',
1826
+ )
1827
+ .option('--thread <threadId>', 'Post prompt to an existing thread')
1828
+ .option(
1829
+ '--session <sessionId>',
1830
+ 'Post prompt to thread mapped to an existing session',
1831
+ )
1832
+ .option(
1833
+ '--wait',
1834
+ 'Wait for session to complete, then print session text to stdout',
1835
+ )
1836
+ .action(
1837
+ async (options: {
1838
+ channel?: string
1839
+ project?: string
1840
+ prompt?: string
1841
+ name?: string
1842
+ appId?: string
1843
+ notifyOnly?: boolean
1844
+ worktree?: string | boolean
1845
+ user?: string
1846
+ agent?: string
1847
+ model?: string
1848
+ sendAt?: string
1849
+ thread?: string
1850
+ session?: string
1851
+ wait?: boolean
1852
+ }) => {
1853
+ try {
1854
+ let {
1855
+ channel: channelId,
1856
+ prompt,
1857
+ name,
1858
+ appId: optionAppId,
1859
+ notifyOnly,
1860
+ thread: threadId,
1861
+ session: sessionId,
1862
+ } = options
1863
+ const { project: projectPath } = options
1864
+ const sendAt = options.sendAt
1865
+
1866
+ const existingThreadMode = Boolean(threadId || sessionId)
1867
+
1868
+ if (threadId && sessionId) {
1869
+ cliLogger.error('Use either --thread or --session, not both')
1870
+ process.exit(EXIT_NO_RESTART)
1871
+ }
1872
+
1873
+ if (existingThreadMode && (channelId || projectPath)) {
1874
+ cliLogger.error(
1875
+ 'Cannot combine --thread/--session with --channel/--project',
1876
+ )
1877
+ process.exit(EXIT_NO_RESTART)
1878
+ }
1879
+
1880
+ // Default to current directory if neither --channel nor --project provided
1881
+ const resolvedProjectPath = existingThreadMode
1882
+ ? undefined
1883
+ : projectPath || (!channelId ? '.' : undefined)
1884
+
1885
+ if (!prompt) {
1886
+ cliLogger.error('Prompt is required. Use --prompt <prompt>')
1887
+ process.exit(EXIT_NO_RESTART)
1888
+ }
1889
+
1890
+ if (sendAt) {
1891
+ if (options.wait) {
1892
+ cliLogger.error('Cannot use --wait with --send-at')
1893
+ process.exit(EXIT_NO_RESTART)
1894
+ }
1895
+ if (prompt.length > 1900) {
1896
+ cliLogger.error(
1897
+ '--send-at currently supports prompts up to 1900 characters',
1898
+ )
1899
+ process.exit(EXIT_NO_RESTART)
1900
+ }
1901
+ }
1902
+
1903
+ const parsedSchedule = (() => {
1904
+ if (!sendAt) {
1905
+ return null
1906
+ }
1907
+ return parseSendAtValue({
1908
+ value: sendAt,
1909
+ now: new Date(),
1910
+ timezone: getLocalTimeZone(),
1911
+ })
1912
+ })()
1913
+ if (parsedSchedule instanceof Error) {
1914
+ cliLogger.error(parsedSchedule.message)
1915
+ if (parsedSchedule.cause instanceof Error) {
1916
+ cliLogger.error(parsedSchedule.cause.message)
1917
+ }
1918
+ process.exit(EXIT_NO_RESTART)
1919
+ }
1920
+
1921
+ if (!existingThreadMode && options.worktree && notifyOnly) {
1922
+ cliLogger.error('Cannot use --worktree with --notify-only')
1923
+ process.exit(EXIT_NO_RESTART)
1924
+ }
1925
+
1926
+ if (options.wait && notifyOnly) {
1927
+ cliLogger.error('Cannot use --wait with --notify-only')
1928
+ process.exit(EXIT_NO_RESTART)
1929
+ }
1930
+
1931
+ if (existingThreadMode) {
1932
+ const incompatibleFlags: string[] = []
1933
+ if (notifyOnly) {
1934
+ incompatibleFlags.push('--notify-only')
1935
+ }
1936
+ if (options.worktree) {
1937
+ incompatibleFlags.push('--worktree')
1938
+ }
1939
+ if (name) {
1940
+ incompatibleFlags.push('--name')
1941
+ }
1942
+ if (options.user) {
1943
+ incompatibleFlags.push('--user')
1944
+ }
1945
+ if (!sendAt && options.agent) {
1946
+ incompatibleFlags.push('--agent')
1947
+ }
1948
+ if (!sendAt && options.model) {
1949
+ incompatibleFlags.push('--model')
1950
+ }
1951
+ if (incompatibleFlags.length > 0) {
1952
+ cliLogger.error(
1953
+ `Incompatible options with --thread/--session: ${incompatibleFlags.join(', ')}`,
1954
+ )
1955
+ process.exit(EXIT_NO_RESTART)
1956
+ }
1957
+ }
1958
+
1959
+ // Initialize database first
1960
+ await initDatabase()
1961
+
1962
+ const { token: botToken, appId } = await resolveBotCredentials({
1963
+ appIdOverride: optionAppId,
1964
+ })
1965
+
1966
+ // If --project provided (or defaulting to cwd), resolve to channel ID
1967
+ if (resolvedProjectPath) {
1968
+ const absolutePath = path.resolve(resolvedProjectPath)
1969
+
1970
+ if (!fs.existsSync(absolutePath)) {
1971
+ cliLogger.error(`Directory does not exist: ${absolutePath}`)
1972
+ process.exit(EXIT_NO_RESTART)
1973
+ }
1974
+
1975
+ cliLogger.log('Looking up channel for project...')
1976
+
1977
+ // Check if channel already exists for this directory or a parent directory
1978
+ // This allows running from subfolders of a registered project
1979
+ try {
1980
+ // Helper to find channel for a path (prefers current bot's channel)
1981
+ const findChannelForPath = async (
1982
+ dirPath: string,
1983
+ ): Promise<
1984
+ { channel_id: string; directory: string } | undefined
1985
+ > => {
1986
+ const withAppId = appId
1987
+ ? await findChannelsByDirectory({
1988
+ directory: dirPath,
1989
+ channelType: 'text',
1990
+ appId,
1991
+ })
1992
+ : []
1993
+ if (withAppId.length > 0) {
1994
+ return withAppId[0]
1995
+ }
1996
+
1997
+ const withoutAppId = await findChannelsByDirectory({
1998
+ directory: dirPath,
1999
+ channelType: 'text',
2000
+ })
2001
+ return withoutAppId[0]
2002
+ }
2003
+
2004
+ // Try exact match first, then walk up parent directories
2005
+ let existingChannel:
2006
+ | { channel_id: string; directory: string }
2007
+ | undefined
2008
+ let searchPath = absolutePath
2009
+ while (searchPath !== path.dirname(searchPath)) {
2010
+ existingChannel = await findChannelForPath(searchPath)
2011
+ if (existingChannel) break
2012
+ searchPath = path.dirname(searchPath)
2013
+ }
2014
+
2015
+ if (existingChannel) {
2016
+ channelId = existingChannel.channel_id
2017
+ if (existingChannel.directory !== absolutePath) {
2018
+ cliLogger.log(
2019
+ `Found parent project channel: ${existingChannel.directory}`,
2020
+ )
2021
+ } else {
2022
+ cliLogger.log(`Found existing channel: ${channelId}`)
2023
+ }
2024
+ } else {
2025
+ // Need to create a new channel
2026
+ cliLogger.log('Creating new channel...')
2027
+
2028
+ if (!appId) {
2029
+ cliLogger.log('Missing app ID')
2030
+ cliLogger.error(
2031
+ 'App ID is required to create channels. Use --app-id or run `kimaki` first.',
2032
+ )
2033
+ process.exit(EXIT_NO_RESTART)
2034
+ }
2035
+
2036
+ const client = await createDiscordClient()
2037
+
2038
+ await new Promise<void>((resolve, reject) => {
2039
+ client.once(Events.ClientReady, () => {
2040
+ resolve()
2041
+ })
2042
+ client.once(Events.Error, reject)
2043
+ client.login(botToken)
2044
+ })
2045
+
2046
+ // Get guild from existing channels or first available
2047
+ const guild = await (async () => {
2048
+ // Try to find a guild from existing channels belonging to this bot
2049
+ const existingChannelId = appId
2050
+ ? await findChannelByAppId(appId)
2051
+ : undefined
2052
+
2053
+ if (existingChannelId) {
2054
+ try {
2055
+ const ch = await client.channels.fetch(existingChannelId)
2056
+ if (ch && 'guild' in ch && ch.guild) {
2057
+ return ch.guild
2058
+ }
2059
+ } catch (error) {
2060
+ cliLogger.debug(
2061
+ 'Failed to fetch existing channel while selecting guild:',
2062
+ error instanceof Error ? error.message : String(error),
2063
+ )
2064
+ }
2065
+ }
2066
+ // Fall back to first guild the bot is in
2067
+ let firstGuild = client.guilds.cache.first()
2068
+ if (!firstGuild) {
2069
+ // Cache might be empty, try fetching guilds from API
2070
+ const fetched = await client.guilds.fetch()
2071
+ const firstOAuth2Guild = fetched.first()
2072
+ if (firstOAuth2Guild) {
2073
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
2074
+ }
2075
+ }
2076
+ if (!firstGuild) {
2077
+ throw new Error(
2078
+ 'No guild found. Add the bot to a server first.',
2079
+ )
2080
+ }
2081
+ return firstGuild
2082
+ })()
2083
+
2084
+ const { textChannelId } = await createProjectChannels({
2085
+ guild,
2086
+ projectDirectory: absolutePath,
2087
+ appId,
2088
+ botName: client.user?.username,
2089
+ })
2090
+
2091
+ channelId = textChannelId
2092
+ cliLogger.log(`Created channel: ${channelId}`)
2093
+
2094
+ client.destroy()
2095
+ }
2096
+ } catch (e) {
2097
+ cliLogger.log('Failed to resolve project')
2098
+ throw e
2099
+ }
2100
+ }
2101
+
2102
+ const rest = createDiscordRest(botToken)
2103
+
2104
+ if (existingThreadMode) {
2105
+ const targetThreadId = await (async (): Promise<string> => {
2106
+ if (threadId) {
2107
+ return threadId
2108
+ }
2109
+ if (!sessionId) {
2110
+ throw new Error('Thread ID not resolved')
2111
+ }
2112
+ const resolvedThreadId = await getThreadIdBySessionId(sessionId)
2113
+ if (!resolvedThreadId) {
2114
+ throw new Error(
2115
+ `No Discord thread found for session: ${sessionId}`,
2116
+ )
2117
+ }
2118
+ return resolvedThreadId
2119
+ })()
2120
+
2121
+ const threadData = (await rest.get(
2122
+ Routes.channel(targetThreadId),
2123
+ )) as {
2124
+ id: string
2125
+ name: string
2126
+ type: number
2127
+ parent_id?: string
2128
+ guild_id: string
2129
+ }
2130
+
2131
+ if (!isThreadChannelType(threadData.type)) {
2132
+ throw new Error(`Channel is not a thread: ${targetThreadId}`)
2133
+ }
2134
+
2135
+ if (!threadData.parent_id) {
2136
+ throw new Error(`Thread has no parent channel: ${targetThreadId}`)
2137
+ }
2138
+
2139
+ const channelConfig = await getChannelDirectory(threadData.parent_id)
2140
+ if (!channelConfig) {
2141
+ throw new Error(
2142
+ 'Thread parent channel is not configured with a project directory',
2143
+ )
2144
+ }
2145
+
2146
+ if (parsedSchedule) {
2147
+ const payload: ScheduledTaskPayload = {
2148
+ kind: 'thread',
2149
+ threadId: targetThreadId,
2150
+ prompt,
2151
+ agent: options.agent || null,
2152
+ model: options.model || null,
2153
+ username: null,
2154
+ userId: null,
2155
+ }
2156
+ const taskId = await createScheduledTask({
2157
+ scheduleKind: parsedSchedule.scheduleKind,
2158
+ runAt: parsedSchedule.runAt,
2159
+ cronExpr: parsedSchedule.cronExpr,
2160
+ timezone: parsedSchedule.timezone,
2161
+ nextRunAt: parsedSchedule.nextRunAt,
2162
+ payloadJson: serializeScheduledTaskPayload(payload),
2163
+ promptPreview: getPromptPreview(prompt),
2164
+ channelId: threadData.parent_id,
2165
+ threadId: targetThreadId,
2166
+ sessionId: sessionId || undefined,
2167
+ projectDirectory: channelConfig.directory,
2168
+ })
2169
+
2170
+ const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
2171
+ note(
2172
+ `Task ID: ${taskId}\nTarget thread: ${threadData.name}\nSchedule: ${formatTaskScheduleLine(parsedSchedule)}\n\nURL: ${threadUrl}`,
2173
+ '✅ Task Scheduled',
2174
+ )
2175
+ cliLogger.log(threadUrl)
2176
+ process.exit(0)
2177
+ }
2178
+
2179
+ const channelAppId = channelConfig.appId || undefined
2180
+ if (channelAppId && appId && channelAppId !== appId) {
2181
+ throw new Error(
2182
+ `Thread belongs to a different bot (expected: ${appId}, got: ${channelAppId})`,
2183
+ )
2184
+ }
2185
+
2186
+ const threadPromptMarker: ThreadStartMarker = {
2187
+ cliThreadPrompt: true,
2188
+ }
2189
+ const promptEmbed = [
2190
+ {
2191
+ color: 0x2b2d31,
2192
+ footer: { text: yaml.dump(threadPromptMarker) },
2193
+ },
2194
+ ]
2195
+
2196
+ // Prefix the prompt so it's clear who sent it (matches /queue format)
2197
+ const prefixedPrompt = `» **kimaki-cli:** ${prompt}`
2198
+
2199
+ await sendDiscordMessageWithOptionalAttachment({
2200
+ channelId: targetThreadId,
2201
+ prompt: prefixedPrompt,
2202
+ botToken,
2203
+ embeds: promptEmbed,
2204
+ rest,
2205
+ })
2206
+
2207
+ const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
2208
+ note(
2209
+ `Prompt sent to thread: ${threadData.name}\n\nURL: ${threadUrl}`,
2210
+ '✅ Message Sent',
2211
+ )
2212
+ cliLogger.log(threadUrl)
2213
+
2214
+ if (options.wait) {
2215
+ const { waitAndOutputSession } = await import('./wait-session.js')
2216
+ await waitAndOutputSession({
2217
+ threadId: targetThreadId,
2218
+ projectDirectory: channelConfig.directory,
2219
+ })
2220
+ }
2221
+
2222
+ process.exit(0)
2223
+ }
2224
+
2225
+ cliLogger.log('Fetching channel info...')
2226
+
2227
+ if (!channelId) {
2228
+ throw new Error('Channel ID not resolved')
2229
+ }
2230
+
2231
+ // Get channel info to extract directory from topic
2232
+ const channelData = (await rest.get(Routes.channel(channelId))) as {
2233
+ id: string
2234
+ name: string
2235
+ topic?: string
2236
+ guild_id: string
2237
+ }
2238
+
2239
+ const channelConfig = await getChannelDirectory(channelData.id)
2240
+
2241
+ if (!channelConfig) {
2242
+ cliLogger.log('Channel not configured')
2243
+ throw new Error(
2244
+ `Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`,
2245
+ )
2246
+ }
2247
+
2248
+ const projectDirectory = channelConfig.directory
2249
+ const channelAppId = channelConfig.appId || undefined
2250
+
2251
+ // Verify app ID matches if both are present
2252
+ if (channelAppId && appId && channelAppId !== appId) {
2253
+ cliLogger.log('Channel belongs to different bot')
2254
+ throw new Error(
2255
+ `Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`,
2256
+ )
2257
+ }
2258
+
2259
+ // Resolve username to user ID if provided
2260
+ const resolvedUser = await (async (): Promise<
2261
+ { id: string; username: string } | undefined
2262
+ > => {
2263
+ if (!options.user) {
2264
+ return undefined
2265
+ }
2266
+ cliLogger.log(`Searching for user "${options.user}" in guild...`)
2267
+ const searchResults = (await rest.get(
2268
+ Routes.guildMembersSearch(channelData.guild_id),
2269
+ {
2270
+ query: new URLSearchParams({ query: options.user, limit: '10' }),
2271
+ },
2272
+ )) as Array<{
2273
+ user: { id: string; username: string; global_name?: string }
2274
+ nick?: string
2275
+ }>
2276
+
2277
+ // Find exact match by display name, nickname, or username
2278
+ const exactMatch = searchResults.find((member) => {
2279
+ const displayName =
2280
+ member.nick || member.user.global_name || member.user.username
2281
+ return (
2282
+ displayName.toLowerCase() === options.user!.toLowerCase() ||
2283
+ member.user.username.toLowerCase() === options.user!.toLowerCase()
2284
+ )
2285
+ })
2286
+ const member = exactMatch || searchResults[0]
2287
+ if (!member) {
2288
+ throw new Error(`User "${options.user}" not found in guild`)
2289
+ }
2290
+ const username =
2291
+ member.nick || member.user.global_name || member.user.username
2292
+ cliLogger.log(`Found user: ${username} (${member.user.id})`)
2293
+ return { id: member.user.id, username }
2294
+ })()
2295
+
2296
+ cliLogger.log('Creating starter message...')
2297
+
2298
+ // Compute thread name and worktree name early (needed for embed)
2299
+ const cleanPrompt = stripMentions(prompt)
2300
+ const baseThreadName =
2301
+ name ||
2302
+ (cleanPrompt.length > 80
2303
+ ? cleanPrompt.slice(0, 77) + '...'
2304
+ : cleanPrompt)
2305
+ const worktreeName = options.worktree
2306
+ ? formatWorktreeName(
2307
+ typeof options.worktree === 'string'
2308
+ ? options.worktree
2309
+ : baseThreadName,
2310
+ )
2311
+ : undefined
2312
+ const threadName = worktreeName
2313
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
2314
+ : baseThreadName
2315
+
2316
+ if (parsedSchedule) {
2317
+ const payload: ScheduledTaskPayload = {
2318
+ kind: 'channel',
2319
+ channelId,
2320
+ prompt,
2321
+ name: name || null,
2322
+ notifyOnly: Boolean(notifyOnly),
2323
+ worktreeName: worktreeName || null,
2324
+ agent: options.agent || null,
2325
+ model: options.model || null,
2326
+ username: resolvedUser?.username || null,
2327
+ userId: resolvedUser?.id || null,
2328
+ }
2329
+ const taskId = await createScheduledTask({
2330
+ scheduleKind: parsedSchedule.scheduleKind,
2331
+ runAt: parsedSchedule.runAt,
2332
+ cronExpr: parsedSchedule.cronExpr,
2333
+ timezone: parsedSchedule.timezone,
2334
+ nextRunAt: parsedSchedule.nextRunAt,
2335
+ payloadJson: serializeScheduledTaskPayload(payload),
2336
+ promptPreview: getPromptPreview(prompt),
2337
+ channelId,
2338
+ projectDirectory,
2339
+ })
2340
+
2341
+ const channelUrl = `https://discord.com/channels/${channelData.guild_id}/${channelId}`
2342
+ note(
2343
+ `Task ID: ${taskId}\nTarget channel: #${channelData.name}\nSchedule: ${formatTaskScheduleLine(parsedSchedule)}\n\nURL: ${channelUrl}`,
2344
+ '✅ Task Scheduled',
2345
+ )
2346
+ cliLogger.log(channelUrl)
2347
+ process.exit(0)
2348
+ }
2349
+
2350
+ // Embed marker for auto-start sessions (unless --notify-only)
2351
+ // Bot parses this YAML to know it should start a session, optionally create a worktree, and set initial user
2352
+ const embedMarker: ThreadStartMarker | undefined = notifyOnly
2353
+ ? undefined
2354
+ : {
2355
+ start: true,
2356
+ ...(worktreeName && { worktree: worktreeName }),
2357
+ ...(resolvedUser && {
2358
+ username: resolvedUser.username,
2359
+ userId: resolvedUser.id,
2360
+ }),
2361
+ ...(options.agent && { agent: options.agent }),
2362
+ ...(options.model && { model: options.model }),
2363
+ }
2364
+ const autoStartEmbed = embedMarker
2365
+ ? [{ color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } }]
2366
+ : undefined
2367
+
2368
+ const starterMessage = await sendDiscordMessageWithOptionalAttachment({
2369
+ channelId,
2370
+ prompt,
2371
+ botToken,
2372
+ embeds: autoStartEmbed,
2373
+ rest,
2374
+ })
2375
+
2376
+ cliLogger.log('Creating thread...')
2377
+
2378
+ const threadData = (await rest.post(
2379
+ Routes.threads(channelId, starterMessage.id),
2380
+ {
2381
+ body: {
2382
+ name: threadName.slice(0, 100),
2383
+ auto_archive_duration: 1440, // 1 day
2384
+ },
2385
+ },
2386
+ )) as { id: string; name: string }
2387
+
2388
+ cliLogger.log('Thread created!')
2389
+
2390
+ // Add user to thread if specified
2391
+ if (resolvedUser) {
2392
+ cliLogger.log(`Adding user ${resolvedUser.username} to thread...`)
2393
+ await rest.put(Routes.threadMembers(threadData.id, resolvedUser.id))
2394
+ }
2395
+
2396
+ const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
2397
+
2398
+ const worktreeNote = worktreeName
2399
+ ? `\nWorktree: ${worktreeName} (will be created by bot)`
2400
+ : ''
2401
+ const successMessage = notifyOnly
2402
+ ? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
2403
+ : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}${worktreeNote}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`
2404
+
2405
+ note(successMessage, '✅ Thread Created')
2406
+
2407
+ cliLogger.log(threadUrl)
2408
+
2409
+ if (options.wait) {
2410
+ const { waitAndOutputSession } = await import('./wait-session.js')
2411
+ await waitAndOutputSession({
2412
+ threadId: threadData.id,
2413
+ projectDirectory,
2414
+ })
2415
+ }
2416
+
2417
+ process.exit(0)
2418
+ } catch (error) {
2419
+ cliLogger.error(
2420
+ 'Error:',
2421
+ error instanceof Error ? error.message : String(error),
2422
+ )
2423
+ process.exit(EXIT_NO_RESTART)
2424
+ }
2425
+ },
2426
+ )
2427
+
2428
+ cli
2429
+ .command('task list', 'List scheduled tasks created via send --send-at')
2430
+ .option('--all', 'Include terminal tasks (completed, cancelled, failed)')
2431
+ .action(async (options: { all?: boolean }) => {
2432
+ try {
2433
+ await initDatabase()
2434
+
2435
+ const statuses = options.all
2436
+ ? undefined
2437
+ : (['planned', 'running'] as Array<'planned' | 'running'>)
2438
+ const tasks = await listScheduledTasks({ statuses })
2439
+ if (tasks.length === 0) {
2440
+ cliLogger.log('No scheduled tasks found')
2441
+ process.exit(0)
2442
+ }
2443
+
2444
+ console.log(
2445
+ 'id | status | message | channelId | projectName | folderName | timeRemaining | firesAt | cron',
2446
+ )
2447
+
2448
+ tasks.forEach((task) => {
2449
+ const projectDirectory = task.project_directory || ''
2450
+ const projectName = projectDirectory
2451
+ ? path.basename(projectDirectory)
2452
+ : '-'
2453
+ const folderName = projectDirectory
2454
+ ? path.basename(path.dirname(projectDirectory))
2455
+ : '-'
2456
+ const firesAt =
2457
+ task.schedule_kind === 'at' && task.run_at
2458
+ ? task.run_at.toISOString()
2459
+ : '-'
2460
+ const cronValue =
2461
+ task.schedule_kind === 'cron' ? task.cron_expr || '-' : '-'
2462
+
2463
+ console.log(
2464
+ `${task.id} | ${task.status} | ${task.prompt_preview} | ${task.channel_id || '-'} | ${projectName} | ${folderName} | ${formatRelativeTime(task.next_run_at)} | ${firesAt} | ${cronValue}`,
2465
+ )
2466
+ })
2467
+
2468
+ process.exit(0)
2469
+ } catch (error) {
2470
+ cliLogger.error(
2471
+ 'Error:',
2472
+ error instanceof Error ? error.message : String(error),
2473
+ )
2474
+ process.exit(EXIT_NO_RESTART)
2475
+ }
2476
+ })
2477
+
2478
+ cli
2479
+ .command('task delete <id>', 'Cancel a scheduled task by ID')
2480
+ .action(async (id: string) => {
2481
+ try {
2482
+ const taskId = Number.parseInt(id, 10)
2483
+ if (Number.isNaN(taskId) || taskId < 1) {
2484
+ cliLogger.error(`Invalid task ID: ${id}`)
2485
+ process.exit(EXIT_NO_RESTART)
2486
+ }
2487
+
2488
+ await initDatabase()
2489
+ const cancelled = await cancelScheduledTask(taskId)
2490
+ if (!cancelled) {
2491
+ cliLogger.error(`Task ${taskId} not found or already finalized`)
2492
+ process.exit(EXIT_NO_RESTART)
2493
+ }
2494
+
2495
+ cliLogger.log(`Cancelled task ${taskId}`)
2496
+ process.exit(0)
2497
+ } catch (error) {
2498
+ cliLogger.error(
2499
+ 'Error:',
2500
+ error instanceof Error ? error.message : String(error),
2501
+ )
2502
+ process.exit(EXIT_NO_RESTART)
2503
+ }
2504
+ })
2505
+
2506
+ cli
2507
+ .command(
2508
+ 'project add [directory]',
2509
+ 'Create Discord channels for a project directory (replaces legacy add-project)',
2510
+ )
2511
+ .alias('add-project')
2512
+ .option(
2513
+ '-g, --guild <guildId>',
2514
+ 'Discord guild/server ID (auto-detects if bot is in only one server)',
2515
+ )
2516
+ .option(
2517
+ '-a, --app-id <appId>',
2518
+ 'Bot application ID (reads from database if available)',
2519
+ )
2520
+ .action(
2521
+ async (
2522
+ directory: string | undefined,
2523
+ options: {
2524
+ guild?: string
2525
+ appId?: string
2526
+ },
2527
+ ) => {
2528
+ const absolutePath = path.resolve(directory || '.')
2529
+
2530
+ if (!fs.existsSync(absolutePath)) {
2531
+ cliLogger.error(`Directory does not exist: ${absolutePath}`)
2532
+ process.exit(EXIT_NO_RESTART)
2533
+ }
2534
+
2535
+ // Initialize database
2536
+ await initDatabase()
2537
+
2538
+ const { token: botToken, appId } = await resolveBotCredentials({
2539
+ appIdOverride: options.appId,
2540
+ })
2541
+
2542
+ if (!appId) {
2543
+ cliLogger.error(
2544
+ 'App ID is required to create channels. Use --app-id or run `kimaki` first.',
2545
+ )
2546
+ process.exit(EXIT_NO_RESTART)
2547
+ }
2548
+
2549
+ cliLogger.log('Connecting to Discord...')
2550
+ const client = await createDiscordClient()
2551
+
2552
+ await new Promise<void>((resolve, reject) => {
2553
+ client.once(Events.ClientReady, () => {
2554
+ resolve()
2555
+ })
2556
+ client.once(Events.Error, reject)
2557
+ client.login(botToken)
2558
+ })
2559
+
2560
+ cliLogger.log('Finding guild...')
2561
+
2562
+ // Find guild
2563
+ let guild: Guild
2564
+ if (options.guild) {
2565
+ const guildId = String(options.guild)
2566
+ const foundGuild = client.guilds.cache.get(guildId)
2567
+ if (!foundGuild) {
2568
+ cliLogger.log('Guild not found')
2569
+ cliLogger.error(`Guild not found: ${guildId}`)
2570
+ client.destroy()
2571
+ process.exit(EXIT_NO_RESTART)
2572
+ }
2573
+ guild = foundGuild
2574
+ } else {
2575
+ // Auto-detect: prefer guild with existing channels for this bot, else first guild
2576
+ const existingChannelId = await findChannelByAppId(appId)
2577
+
2578
+ if (existingChannelId) {
2579
+ try {
2580
+ const ch = await client.channels.fetch(existingChannelId)
2581
+ if (ch && 'guild' in ch && ch.guild) {
2582
+ guild = ch.guild
2583
+ } else {
2584
+ throw new Error('Channel has no guild')
2585
+ }
2586
+ } catch (error) {
2587
+ cliLogger.debug(
2588
+ 'Failed to fetch existing channel while selecting guild:',
2589
+ error instanceof Error ? error.message : String(error),
2590
+ )
2591
+ let firstGuild = client.guilds.cache.first()
2592
+ if (!firstGuild) {
2593
+ // Cache might be empty, try fetching guilds from API
2594
+ const fetched = await client.guilds.fetch()
2595
+ const firstOAuth2Guild = fetched.first()
2596
+ if (firstOAuth2Guild) {
2597
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
2598
+ }
2599
+ }
2600
+ if (!firstGuild) {
2601
+ cliLogger.log('No guild found')
2602
+ cliLogger.error('No guild found. Add the bot to a server first.')
2603
+ client.destroy()
2604
+ process.exit(EXIT_NO_RESTART)
2605
+ }
2606
+ guild = firstGuild
2607
+ }
2608
+ } else {
2609
+ let firstGuild = client.guilds.cache.first()
2610
+ if (!firstGuild) {
2611
+ // Cache might be empty, try fetching guilds from API
2612
+ const fetched = await client.guilds.fetch()
2613
+ const firstOAuth2Guild = fetched.first()
2614
+ if (firstOAuth2Guild) {
2615
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
2616
+ }
2617
+ }
2618
+ if (!firstGuild) {
2619
+ cliLogger.log('No guild found')
2620
+ cliLogger.error('No guild found. Add the bot to a server first.')
2621
+ client.destroy()
2622
+ process.exit(EXIT_NO_RESTART)
2623
+ }
2624
+ guild = firstGuild
2625
+ }
2626
+ }
2627
+
2628
+ // Check if channel already exists in this guild
2629
+ cliLogger.log('Checking for existing channel...')
2630
+ try {
2631
+ const existingChannels = await findChannelsByDirectory({
2632
+ directory: absolutePath,
2633
+ channelType: 'text',
2634
+ appId,
2635
+ })
2636
+
2637
+ for (const existingChannel of existingChannels) {
2638
+ try {
2639
+ const ch = await client.channels.fetch(existingChannel.channel_id)
2640
+ if (ch && 'guild' in ch && ch.guild?.id === guild.id) {
2641
+ client.destroy()
2642
+ cliLogger.error(
2643
+ `Channel already exists for this directory in ${guild.name}. Channel ID: ${existingChannel.channel_id}`,
2644
+ )
2645
+ process.exit(EXIT_NO_RESTART)
2646
+ }
2647
+ } catch (error) {
2648
+ cliLogger.debug(
2649
+ `Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`,
2650
+ error instanceof Error ? error.message : String(error),
2651
+ )
2652
+ }
2653
+ }
2654
+ } catch (error) {
2655
+ cliLogger.debug(
2656
+ 'Database lookup failed while checking existing channels:',
2657
+ error instanceof Error ? error.message : String(error),
2658
+ )
2659
+ }
2660
+
2661
+ cliLogger.log(`Creating channels in ${guild.name}...`)
2662
+
2663
+ const { textChannelId, voiceChannelId, channelName } =
2664
+ await createProjectChannels({
2665
+ guild,
2666
+ projectDirectory: absolutePath,
2667
+ appId,
2668
+ botName: client.user?.username,
2669
+ })
2670
+
2671
+ client.destroy()
2672
+
2673
+ cliLogger.log('Channels created!')
2674
+
2675
+ const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`
2676
+
2677
+ note(
2678
+ `Created channels for project:\n\n📝 Text: #${channelName}\n🔊 Voice: #${channelName}\n📁 Directory: ${absolutePath}\n\nURL: ${channelUrl}`,
2679
+ '✅ Success',
2680
+ )
2681
+
2682
+ cliLogger.log(channelUrl)
2683
+ process.exit(0)
2684
+ },
2685
+ )
2686
+
2687
+ cli
2688
+ .command(
2689
+ 'project list',
2690
+ 'List all registered projects with their Discord channels',
2691
+ )
2692
+ .option('--json', 'Output as JSON')
2693
+ .action(async (options: { json?: boolean }) => {
2694
+ await initDatabase()
2695
+
2696
+ const prisma = await getPrisma()
2697
+ const channels = await prisma.channel_directories.findMany({
2698
+ where: { channel_type: 'text' },
2699
+ orderBy: { created_at: 'desc' },
2700
+ })
2701
+
2702
+ if (channels.length === 0) {
2703
+ cliLogger.log('No projects registered')
2704
+ process.exit(0)
2705
+ }
2706
+
2707
+ // Fetch Discord channel names via REST API
2708
+ const botRow = getBotToken({ preferEnv: false })
2709
+ const rest = botRow ? createDiscordRest(botRow.token) : null
2710
+
2711
+ const enriched = await Promise.all(
2712
+ channels.map(async (ch) => {
2713
+ let channelName = ''
2714
+ if (rest) {
2715
+ try {
2716
+ const data = (await rest.get(Routes.channel(ch.channel_id))) as {
2717
+ name?: string
2718
+ }
2719
+ channelName = data.name || ''
2720
+ } catch {
2721
+ // Channel may have been deleted from Discord
2722
+ }
2723
+ }
2724
+ return { ...ch, channelName }
2725
+ }),
2726
+ )
2727
+
2728
+ if (options.json) {
2729
+ const output = enriched.map((ch) => ({
2730
+ channel_id: ch.channel_id,
2731
+ channel_name: ch.channelName,
2732
+ directory: ch.directory,
2733
+ folder_name: path.basename(ch.directory),
2734
+ app_id: ch.app_id,
2735
+ }))
2736
+ console.log(JSON.stringify(output, null, 2))
2737
+ process.exit(0)
2738
+ }
2739
+
2740
+ for (const ch of enriched) {
2741
+ const folderName = path.basename(ch.directory)
2742
+ const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id
2743
+ console.log(`\n${channelLabel}`)
2744
+ console.log(` Folder: ${folderName}`)
2745
+ console.log(` Directory: ${ch.directory}`)
2746
+ console.log(` Channel ID: ${ch.channel_id}`)
2747
+ if (ch.app_id) {
2748
+ console.log(` Bot App ID: ${ch.app_id}`)
2749
+ }
2750
+ }
2751
+
2752
+ process.exit(0)
2753
+ })
2754
+
2755
+ cli
2756
+ .command(
2757
+ 'project open-in-discord',
2758
+ 'Open the current project channel in Discord',
2759
+ )
2760
+ .action(async () => {
2761
+ await initDatabase()
2762
+
2763
+ const botRow = getBotToken({ preferEnv: false })
2764
+ if (!botRow) {
2765
+ cliLogger.error('No bot configured. Run `kimaki` first.')
2766
+ process.exit(EXIT_NO_RESTART)
2767
+ }
2768
+
2769
+ const { appId, token: botToken } = botRow
2770
+ const absolutePath = path.resolve('.')
2771
+
2772
+ // Walk up parent directories to find a matching channel
2773
+ const findChannelForPath = async (
2774
+ dirPath: string,
2775
+ ): Promise<{ channel_id: string; directory: string } | undefined> => {
2776
+ const withAppId = appId
2777
+ ? await findChannelsByDirectory({
2778
+ directory: dirPath,
2779
+ channelType: 'text',
2780
+ appId,
2781
+ })
2782
+ : []
2783
+ if (withAppId.length > 0) {
2784
+ return withAppId[0]
2785
+ }
2786
+ const withoutAppId = await findChannelsByDirectory({
2787
+ directory: dirPath,
2788
+ channelType: 'text',
2789
+ })
2790
+ return withoutAppId[0]
2791
+ }
2792
+
2793
+ let existingChannel: { channel_id: string; directory: string } | undefined
2794
+ let searchPath = absolutePath
2795
+ do {
2796
+ existingChannel = await findChannelForPath(searchPath)
2797
+ if (existingChannel) {
2798
+ break
2799
+ }
2800
+ const parent = path.dirname(searchPath)
2801
+ if (parent === searchPath) {
2802
+ break
2803
+ }
2804
+ searchPath = parent
2805
+ } while (true)
2806
+
2807
+ if (!existingChannel) {
2808
+ cliLogger.error(`No project channel found for ${absolutePath}`)
2809
+ process.exit(EXIT_NO_RESTART)
2810
+ }
2811
+
2812
+ // Fetch channel from Discord to get guild_id
2813
+ const rest = createDiscordRest(botToken)
2814
+ const channelData = (await rest.get(
2815
+ Routes.channel(existingChannel.channel_id),
2816
+ )) as {
2817
+ id: string
2818
+ guild_id: string
2819
+ }
2820
+
2821
+ const channelUrl = `https://discord.com/channels/${channelData.guild_id}/${channelData.id}`
2822
+ cliLogger.log(channelUrl)
2823
+
2824
+ // Open in browser if running in a TTY
2825
+ if (process.stdout.isTTY) {
2826
+ if (process.platform === 'win32') {
2827
+ spawn('cmd', ['/c', 'start', '', channelUrl], {
2828
+ detached: true,
2829
+ stdio: 'ignore',
2830
+ }).unref()
2831
+ } else {
2832
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open'
2833
+ spawn(openCmd, [channelUrl], {
2834
+ detached: true,
2835
+ stdio: 'ignore',
2836
+ }).unref()
2837
+ }
2838
+ }
2839
+
2840
+ process.exit(0)
2841
+ })
2842
+
2843
+ cli
2844
+ .command(
2845
+ 'project create <name>',
2846
+ 'Create a new project folder with git and Discord channels',
2847
+ )
2848
+ .option('-g, --guild <guildId>', 'Discord guild ID')
2849
+ .action(async (name: string, options: { guild?: string }) => {
2850
+ const sanitizedName = name
2851
+ .toLowerCase()
2852
+ .replace(/[^a-z0-9-]/g, '-')
2853
+ .replace(/-+/g, '-')
2854
+ .replace(/^-|-$/g, '')
2855
+ .slice(0, 100)
2856
+
2857
+ if (!sanitizedName) {
2858
+ cliLogger.error('Invalid project name')
2859
+ process.exit(EXIT_NO_RESTART)
2860
+ }
2861
+
2862
+ await initDatabase()
2863
+
2864
+ const botRow = getBotToken({ preferEnv: false })
2865
+ if (!botRow) {
2866
+ cliLogger.error('No bot configured. Run `kimaki` first.')
2867
+ process.exit(EXIT_NO_RESTART)
2868
+ }
2869
+
2870
+ const { appId, token: botToken } = botRow
2871
+ if (!appId) {
2872
+ cliLogger.error(
2873
+ 'App ID is required to create channels. Re-run setup with `kimaki --restart`.',
2874
+ )
2875
+ process.exit(EXIT_NO_RESTART)
2876
+ }
2877
+
2878
+ const projectsDir = getProjectsDir()
2879
+ const projectDirectory = path.join(projectsDir, sanitizedName)
2880
+
2881
+ if (!fs.existsSync(projectsDir)) {
2882
+ fs.mkdirSync(projectsDir, { recursive: true })
2883
+ }
2884
+
2885
+ if (fs.existsSync(projectDirectory)) {
2886
+ cliLogger.error(`Directory already exists: ${projectDirectory}`)
2887
+ process.exit(EXIT_NO_RESTART)
2888
+ }
2889
+
2890
+ fs.mkdirSync(projectDirectory, { recursive: true })
2891
+ cliLogger.log(`Created: ${projectDirectory}`)
2892
+
2893
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
2894
+ cliLogger.log('Initialized git')
2895
+
2896
+ cliLogger.log('Connecting to Discord...')
2897
+ const client = await createDiscordClient()
2898
+
2899
+ await new Promise<void>((resolve, reject) => {
2900
+ client.once(Events.ClientReady, () => {
2901
+ resolve()
2902
+ })
2903
+ client.once(Events.Error, reject)
2904
+ client.login(botToken).catch(reject)
2905
+ })
2906
+
2907
+ let guild: Guild
2908
+ if (options.guild) {
2909
+ const found = client.guilds.cache.get(options.guild)
2910
+ if (!found) {
2911
+ cliLogger.error(`Guild not found: ${options.guild}`)
2912
+ client.destroy()
2913
+ process.exit(EXIT_NO_RESTART)
2914
+ }
2915
+ guild = found
2916
+ } else {
2917
+ const first = client.guilds.cache.first()
2918
+ if (!first) {
2919
+ cliLogger.error('No guild found. Add the bot to a server first.')
2920
+ client.destroy()
2921
+ process.exit(EXIT_NO_RESTART)
2922
+ }
2923
+ guild = first
2924
+ }
2925
+
2926
+ const { textChannelId, channelName } = await createProjectChannels({
2927
+ guild,
2928
+ projectDirectory,
2929
+ appId,
2930
+ botName: client.user?.username,
2931
+ })
2932
+
2933
+ client.destroy()
2934
+
2935
+ const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`
2936
+
2937
+ note(
2938
+ `Created project: ${sanitizedName}\n\nDirectory: ${projectDirectory}\nChannel: #${channelName}\nURL: ${channelUrl}`,
2939
+ '✅ Success',
2940
+ )
2941
+
2942
+ cliLogger.log(channelUrl)
2943
+ process.exit(0)
2944
+ })
2945
+
2946
+ cli
2947
+ .command('tunnel', 'Expose a local port via tunnel')
2948
+ .option('-p, --port <port>', 'Local port to expose (required)')
2949
+ .option(
2950
+ '-t, --tunnel-id [id]',
2951
+ 'Custom tunnel ID (only for services safe to expose publicly; prefer random default)',
2952
+ )
2953
+ .option('-h, --host [host]', 'Local host (default: localhost)')
2954
+ .option('-s, --server [url]', 'Tunnel server URL')
2955
+ .action(
2956
+ async (options: {
2957
+ port?: string
2958
+ tunnelId?: string
2959
+ host?: string
2960
+ server?: string
2961
+ }) => {
2962
+ const { runTunnel, parseCommandFromArgv, CLI_NAME } = await import(
2963
+ 'traforo/run-tunnel'
2964
+ )
2965
+
2966
+ if (!options.port) {
2967
+ cliLogger.error('Error: --port is required')
2968
+ cliLogger.error(`\nUsage: kimaki tunnel -p <port> [-- command]`)
2969
+ process.exit(EXIT_NO_RESTART)
2970
+ }
2971
+
2972
+ const port = parseInt(options.port, 10)
2973
+ if (isNaN(port) || port < 1 || port > 65535) {
2974
+ cliLogger.error(`Error: Invalid port number: ${options.port}`)
2975
+ process.exit(EXIT_NO_RESTART)
2976
+ }
2977
+
2978
+ // Parse command after -- from argv
2979
+ const { command } = parseCommandFromArgv(process.argv)
2980
+
2981
+ await runTunnel({
2982
+ port,
2983
+ tunnelId: options.tunnelId,
2984
+ localHost: options.host,
2985
+ baseDomain: 'kimaki.xyz',
2986
+ serverUrl: options.server,
2987
+ command: command.length > 0 ? command : undefined,
2988
+ })
2989
+ },
2990
+ )
2991
+
2992
+ cli
2993
+ .command('sqlitedb', 'Show the location of the SQLite database file')
2994
+ .action(() => {
2995
+ const dataDir = getDataDir()
2996
+ const dbPath = path.join(dataDir, 'discord-sessions.db')
2997
+ cliLogger.log(dbPath)
2998
+ })
2999
+
3000
+ cli
3001
+ .command(
3002
+ 'session list',
3003
+ 'List all OpenCode sessions, marking which were started via Kimaki',
3004
+ )
3005
+ .option(
3006
+ '--project <path>',
3007
+ 'Project directory to list sessions for (defaults to cwd)',
3008
+ )
3009
+ .option('--json', 'Output as JSON')
3010
+ .action(async (options: { project?: string; json?: boolean }) => {
3011
+ try {
3012
+ const projectDirectory = path.resolve(options.project || '.')
3013
+
3014
+ await initDatabase()
3015
+
3016
+ cliLogger.log('Connecting to OpenCode server...')
3017
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
3018
+ if (getClient instanceof Error) {
3019
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message)
3020
+ process.exit(EXIT_NO_RESTART)
3021
+ }
3022
+
3023
+ const sessionsResponse = await getClient().session.list()
3024
+ const sessions = sessionsResponse.data || []
3025
+
3026
+ if (sessions.length === 0) {
3027
+ cliLogger.log('No sessions found')
3028
+ process.exit(0)
3029
+ }
3030
+
3031
+ // Look up which sessions were started via kimaki (have a thread mapping)
3032
+ const prisma = await getPrisma()
3033
+ const threadSessions = await prisma.thread_sessions.findMany({
3034
+ select: { thread_id: true, session_id: true },
3035
+ })
3036
+ const sessionToThread = new Map(
3037
+ threadSessions
3038
+ .filter((row) => row.session_id !== '')
3039
+ .map((row) => [row.session_id, row.thread_id]),
3040
+ )
3041
+ const sessionStartSources = await getSessionStartSourcesBySessionIds(
3042
+ sessions.map((session) => session.id),
3043
+ )
3044
+
3045
+ const scheduleModeLabel = ({
3046
+ scheduleKind,
3047
+ }: {
3048
+ scheduleKind: 'at' | 'cron'
3049
+ }): 'delay' | 'cron' => {
3050
+ if (scheduleKind === 'at') {
3051
+ return 'delay'
3052
+ }
3053
+ return 'cron'
3054
+ }
3055
+
3056
+ if (options.json) {
3057
+ const output = sessions.map((session) => {
3058
+ const startSource = sessionStartSources.get(session.id)
3059
+ const startedBy = startSource
3060
+ ? `scheduled-${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}`
3061
+ : null
3062
+ return {
3063
+ id: session.id,
3064
+ title: session.title || 'Untitled Session',
3065
+ directory: session.directory,
3066
+ updated: new Date(session.time.updated).toISOString(),
3067
+ source: sessionToThread.has(session.id) ? 'kimaki' : 'opencode',
3068
+ threadId: sessionToThread.get(session.id) || null,
3069
+ startedBy,
3070
+ scheduledTaskId: startSource?.scheduled_task_id || null,
3071
+ }
3072
+ })
3073
+ console.log(JSON.stringify(output, null, 2))
3074
+ process.exit(0)
3075
+ }
3076
+
3077
+ for (const session of sessions) {
3078
+ const threadId = sessionToThread.get(session.id)
3079
+ const startSource = sessionStartSources.get(session.id)
3080
+ const source = threadId ? '(kimaki)' : '(opencode)'
3081
+ const startedBy = startSource
3082
+ ? ` | started-by: ${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}${startSource.scheduled_task_id ? ` (#${startSource.scheduled_task_id})` : ''}`
3083
+ : ''
3084
+ const updatedAt = new Date(session.time.updated).toISOString()
3085
+ const threadInfo = threadId ? ` | thread: ${threadId}` : ''
3086
+ console.log(
3087
+ `${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${threadInfo}${startedBy}`,
3088
+ )
3089
+ }
3090
+
3091
+ process.exit(0)
3092
+ } catch (error) {
3093
+ cliLogger.error(
3094
+ 'Error:',
3095
+ error instanceof Error ? error.message : String(error),
3096
+ )
3097
+ process.exit(EXIT_NO_RESTART)
3098
+ }
3099
+ })
3100
+
3101
+ cli
3102
+ .command(
3103
+ 'session read <sessionId>',
3104
+ 'Read a session conversation as markdown (pipe to file to grep)',
3105
+ )
3106
+ .option('--project <path>', 'Project directory (defaults to cwd)')
3107
+ .action(async (sessionId: string, options: { project?: string }) => {
3108
+ try {
3109
+ const projectDirectory = path.resolve(options.project || '.')
3110
+
3111
+ await initDatabase()
3112
+
3113
+ cliLogger.log('Connecting to OpenCode server...')
3114
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
3115
+ if (getClient instanceof Error) {
3116
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message)
3117
+ process.exit(EXIT_NO_RESTART)
3118
+ }
3119
+
3120
+ // Try current project first (fast path)
3121
+ const markdown = new ShareMarkdown(getClient())
3122
+ const result = await markdown.generate({ sessionID: sessionId })
3123
+ if (!(result instanceof Error)) {
3124
+ process.stdout.write(result)
3125
+ process.exit(0)
3126
+ }
3127
+
3128
+ // Session not found in current project, search across all projects.
3129
+ // project.list() returns all known projects globally from any OpenCode server,
3130
+ // but session.list/get are scoped to the server's own project. So we try each.
3131
+ cliLogger.log('Session not in current project, searching all projects...')
3132
+ const projectsResponse = await getClient().project.list()
3133
+ const projects = projectsResponse.data || []
3134
+ const otherProjects = projects
3135
+ .filter((p) => path.resolve(p.worktree) !== projectDirectory)
3136
+ .filter((p) => {
3137
+ try {
3138
+ fs.accessSync(p.worktree, fs.constants.R_OK)
3139
+ return true
3140
+ } catch {
3141
+ return false
3142
+ }
3143
+ })
3144
+ // Sort by most recently created first to find sessions faster
3145
+ .sort((a, b) => b.time.created - a.time.created)
3146
+
3147
+ for (const project of otherProjects) {
3148
+ const dir = project.worktree
3149
+ cliLogger.log(`Trying project: ${dir}`)
3150
+ const otherClient = await initializeOpencodeForDirectory(dir)
3151
+ if (otherClient instanceof Error) {
3152
+ continue
3153
+ }
3154
+ const otherMarkdown = new ShareMarkdown(otherClient())
3155
+ const otherResult = await otherMarkdown.generate({
3156
+ sessionID: sessionId,
3157
+ })
3158
+ if (!(otherResult instanceof Error)) {
3159
+ process.stdout.write(otherResult)
3160
+ process.exit(0)
3161
+ }
3162
+ }
3163
+
3164
+ cliLogger.error(`Session ${sessionId} not found in any project`)
3165
+ process.exit(EXIT_NO_RESTART)
3166
+ } catch (error) {
3167
+ cliLogger.error(
3168
+ 'Error:',
3169
+ error instanceof Error ? error.message : String(error),
3170
+ )
3171
+ process.exit(EXIT_NO_RESTART)
3172
+ }
3173
+ })
3174
+
3175
+ cli
3176
+ .command(
3177
+ 'session search <query>',
3178
+ 'Search past sessions for text or /regex/flags in the selected project',
3179
+ )
3180
+ .option('--project <path>', 'Project directory (defaults to cwd)')
3181
+ .option('--channel <channelId>', 'Resolve project from a Discord channel ID')
3182
+ .option('--limit <n>', 'Maximum matched sessions to return (default: 20)')
3183
+ .option('--json', 'Output as JSON')
3184
+ .action(async (query, options) => {
3185
+ try {
3186
+ await initDatabase()
3187
+
3188
+ if (options.project && options.channel) {
3189
+ cliLogger.error('Use either --project or --channel, not both')
3190
+ process.exit(EXIT_NO_RESTART)
3191
+ }
3192
+
3193
+ const limit = (() => {
3194
+ const rawLimit =
3195
+ typeof options.limit === 'string' ? options.limit : '20'
3196
+ const parsed = Number.parseInt(rawLimit, 10)
3197
+ if (Number.isNaN(parsed) || parsed < 1) {
3198
+ return new Error(`Invalid --limit value: ${rawLimit}`)
3199
+ }
3200
+ return parsed
3201
+ })()
3202
+
3203
+ if (limit instanceof Error) {
3204
+ cliLogger.error(limit.message)
3205
+ process.exit(EXIT_NO_RESTART)
3206
+ }
3207
+
3208
+ const projectDirectoryResult = await (async (): Promise<
3209
+ string | Error
3210
+ > => {
3211
+ if (options.channel) {
3212
+ const channelConfig = await getChannelDirectory(options.channel)
3213
+ if (!channelConfig) {
3214
+ return new Error(
3215
+ `No project mapping found for channel: ${options.channel}`,
3216
+ )
3217
+ }
3218
+ return path.resolve(channelConfig.directory)
3219
+ }
3220
+ return path.resolve(options.project || '.')
3221
+ })()
3222
+
3223
+ if (projectDirectoryResult instanceof Error) {
3224
+ cliLogger.error(projectDirectoryResult.message)
3225
+ process.exit(EXIT_NO_RESTART)
3226
+ }
3227
+
3228
+ const projectDirectory = projectDirectoryResult
3229
+ if (!fs.existsSync(projectDirectory)) {
3230
+ cliLogger.error(`Directory does not exist: ${projectDirectory}`)
3231
+ process.exit(EXIT_NO_RESTART)
3232
+ }
3233
+
3234
+ const searchPattern = parseSessionSearchPattern(query)
3235
+ if (searchPattern instanceof Error) {
3236
+ cliLogger.error(searchPattern.message)
3237
+ process.exit(EXIT_NO_RESTART)
3238
+ }
3239
+
3240
+ cliLogger.log('Connecting to OpenCode server...')
3241
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
3242
+ if (getClient instanceof Error) {
3243
+ cliLogger.error('Failed to connect to OpenCode:', getClient.message)
3244
+ process.exit(EXIT_NO_RESTART)
3245
+ }
3246
+
3247
+ const sessionsResponse = await getClient().session.list()
3248
+ const sessions = sessionsResponse.data || []
3249
+ if (sessions.length === 0) {
3250
+ cliLogger.log('No sessions found')
3251
+ process.exit(0)
3252
+ }
3253
+
3254
+ const prisma = await getPrisma()
3255
+ const threadSessions = await prisma.thread_sessions.findMany({
3256
+ select: { thread_id: true, session_id: true },
3257
+ })
3258
+ const sessionToThread = new Map(
3259
+ threadSessions
3260
+ .filter((row) => row.session_id !== '')
3261
+ .map((row) => [row.session_id, row.thread_id]),
3262
+ )
3263
+
3264
+ const sortedSessions = [...sessions].sort((a, b) => {
3265
+ return b.time.updated - a.time.updated
3266
+ })
3267
+
3268
+ const matchedSessions: Array<{
3269
+ id: string
3270
+ title: string
3271
+ directory: string
3272
+ updated: string
3273
+ source: 'kimaki' | 'opencode'
3274
+ threadId: string | null
3275
+ snippets: string[]
3276
+ }> = []
3277
+
3278
+ let scannedSessions = 0
3279
+
3280
+ for (const session of sortedSessions) {
3281
+ scannedSessions++
3282
+ const messagesResponse = await getClient().session.messages({
3283
+ sessionID: session.id,
3284
+ })
3285
+ const messages = messagesResponse.data || []
3286
+
3287
+ const snippets = messages
3288
+ .flatMap((message) => {
3289
+ const rolePrefix =
3290
+ message.info.role === 'assistant'
3291
+ ? 'assistant'
3292
+ : message.info.role === 'user'
3293
+ ? 'user'
3294
+ : 'message'
3295
+
3296
+ return message.parts.filter((p) => !(p.type === 'text' && p.synthetic)).flatMap((part) => {
3297
+ return getPartSearchTexts(part).flatMap((text) => {
3298
+ const hit = findFirstSessionSearchHit({
3299
+ text,
3300
+ searchPattern,
3301
+ })
3302
+ if (!hit) {
3303
+ return []
3304
+ }
3305
+ const snippet = buildSessionSearchSnippet({ text, hit })
3306
+ if (!snippet) {
3307
+ return []
3308
+ }
3309
+ return [`${rolePrefix}: ${snippet}`]
3310
+ })
3311
+ })
3312
+ })
3313
+ .slice(0, 3)
3314
+
3315
+ if (snippets.length === 0) {
3316
+ continue
3317
+ }
3318
+
3319
+ const threadId = sessionToThread.get(session.id)
3320
+ matchedSessions.push({
3321
+ id: session.id,
3322
+ title: session.title || 'Untitled Session',
3323
+ directory: session.directory,
3324
+ updated: new Date(session.time.updated).toISOString(),
3325
+ source: threadId ? 'kimaki' : 'opencode',
3326
+ threadId: threadId || null,
3327
+ snippets,
3328
+ })
3329
+
3330
+ if (matchedSessions.length >= limit) {
3331
+ break
3332
+ }
3333
+ }
3334
+
3335
+ if (options.json) {
3336
+ console.log(
3337
+ JSON.stringify(
3338
+ {
3339
+ query: searchPattern.raw,
3340
+ mode: searchPattern.mode,
3341
+ projectDirectory,
3342
+ scannedSessions,
3343
+ matches: matchedSessions,
3344
+ },
3345
+ null,
3346
+ 2,
3347
+ ),
3348
+ )
3349
+ process.exit(0)
3350
+ }
3351
+
3352
+ if (matchedSessions.length === 0) {
3353
+ cliLogger.log(
3354
+ `No matches found for ${searchPattern.raw} in ${projectDirectory} (${scannedSessions} sessions scanned)`,
3355
+ )
3356
+ process.exit(0)
3357
+ }
3358
+
3359
+ cliLogger.log(
3360
+ `Found ${matchedSessions.length} matching session(s) for ${searchPattern.raw} in ${projectDirectory}`,
3361
+ )
3362
+
3363
+ for (const match of matchedSessions) {
3364
+ const threadInfo = match.threadId ? ` | thread: ${match.threadId}` : ''
3365
+ console.log(
3366
+ `${match.id} | ${match.title} | ${match.updated} | ${match.source}${threadInfo}`,
3367
+ )
3368
+ console.log(` Directory: ${match.directory}`)
3369
+ match.snippets.forEach((snippet) => {
3370
+ console.log(` - ${snippet}`)
3371
+ })
3372
+ }
3373
+
3374
+ process.exit(0)
3375
+ } catch (error) {
3376
+ cliLogger.error(
3377
+ 'Error:',
3378
+ error instanceof Error ? error.message : String(error),
3379
+ )
3380
+ process.exit(EXIT_NO_RESTART)
3381
+ }
3382
+ })
3383
+
3384
+ cli
3385
+ .command(
3386
+ 'session archive <threadId>',
3387
+ 'Archive a Discord thread and stop its mapped OpenCode session',
3388
+ )
3389
+ .action(async (threadId: string) => {
3390
+ try {
3391
+ await initDatabase()
3392
+
3393
+ const { token: botToken } = await resolveBotCredentials()
3394
+
3395
+ const rest = createDiscordRest(botToken)
3396
+ const threadData = (await rest.get(Routes.channel(threadId))) as {
3397
+ id: string
3398
+ type: number
3399
+ name?: string
3400
+ parent_id?: string
3401
+ }
3402
+
3403
+ if (!isThreadChannelType(threadData.type)) {
3404
+ cliLogger.error(`Channel is not a thread: ${threadId}`)
3405
+ process.exit(EXIT_NO_RESTART)
3406
+ }
3407
+
3408
+ const sessionId = await getThreadSession(threadId)
3409
+ let client: OpencodeClient | null = null
3410
+ if (sessionId && threadData.parent_id) {
3411
+ const channelConfig = await getChannelDirectory(threadData.parent_id)
3412
+ if (!channelConfig) {
3413
+ cliLogger.warn(
3414
+ `No channel directory mapping found for parent channel ${threadData.parent_id}`,
3415
+ )
3416
+ } else {
3417
+ const getClient = await initializeOpencodeForDirectory(
3418
+ channelConfig.directory,
3419
+ )
3420
+ if (getClient instanceof Error) {
3421
+ cliLogger.warn(
3422
+ `Could not initialize OpenCode for ${channelConfig.directory}: ${getClient.message}`,
3423
+ )
3424
+ } else {
3425
+ client = getClient()
3426
+ }
3427
+ }
3428
+ } else {
3429
+ cliLogger.warn(
3430
+ `No mapped OpenCode session found for thread ${threadId}`,
3431
+ )
3432
+ }
3433
+
3434
+ await archiveThread({
3435
+ rest,
3436
+ threadId,
3437
+ parentChannelId: threadData.parent_id,
3438
+ sessionId,
3439
+ client,
3440
+ })
3441
+
3442
+ const threadLabel = threadData.name || threadId
3443
+ note(
3444
+ `Archived thread: ${threadLabel}\nThread ID: ${threadId}`,
3445
+ '✅ Archived',
3446
+ )
3447
+ process.exit(0)
3448
+ } catch (error) {
3449
+ cliLogger.error(
3450
+ 'Error:',
3451
+ error instanceof Error ? error.message : String(error),
3452
+ )
3453
+ process.exit(EXIT_NO_RESTART)
3454
+ }
3455
+ })
3456
+
3457
+ cli
3458
+ .command(
3459
+ 'upgrade',
3460
+ 'Upgrade kimaki to the latest version and restart the running bot',
3461
+ )
3462
+ .option('--skip-restart', 'Only upgrade, do not restart the running bot')
3463
+ .action(async (options) => {
3464
+ try {
3465
+ const current = getCurrentVersion()
3466
+ cliLogger.log(`Current version: v${current}`)
3467
+
3468
+ const newVersion = await upgrade()
3469
+ if (!newVersion) {
3470
+ cliLogger.log('Already on latest version')
3471
+ process.exit(0)
3472
+ }
3473
+
3474
+ cliLogger.log(`Upgraded to v${newVersion}`)
3475
+
3476
+ if (options.skipRestart) {
3477
+ process.exit(0)
3478
+ }
3479
+
3480
+ // Spawn a new kimaki process without args (starts the bot with default command).
3481
+ // The new process kills the old one via the single-instance lock.
3482
+ // No args passed to avoid recursively running `upgrade` again.
3483
+ const child = spawn('kimaki', [], {
3484
+ shell: true,
3485
+ stdio: 'ignore',
3486
+ detached: true,
3487
+ })
3488
+ child.unref()
3489
+ cliLogger.log('Restarting bot with new version...')
3490
+ process.exit(0)
3491
+ } catch (error) {
3492
+ cliLogger.error(
3493
+ 'Upgrade failed:',
3494
+ error instanceof Error ? error.message : String(error),
3495
+ )
3496
+ process.exit(EXIT_NO_RESTART)
3497
+ }
3498
+ })
3499
+
3500
+ cli
3501
+ .command(
3502
+ 'worktree merge',
3503
+ 'Merge worktree branch into default branch using worktrunk-style pipeline',
3504
+ )
3505
+ .option('-d, --directory <path>', 'Worktree directory (defaults to cwd)')
3506
+ .option(
3507
+ '-m, --main-repo <path>',
3508
+ 'Main repository directory (auto-detected from worktree)',
3509
+ )
3510
+ .option(
3511
+ '-n, --name <name>',
3512
+ 'Worktree/branch name (auto-detected from branch)',
3513
+ )
3514
+ .action(
3515
+ async (options: {
3516
+ directory?: string
3517
+ mainRepo?: string
3518
+ name?: string
3519
+ }) => {
3520
+ try {
3521
+ const { mergeWorktree } = await import('./worktree-utils.js')
3522
+ const worktreeDir = path.resolve(options.directory || '.')
3523
+
3524
+ // Auto-detect main repo: find the main worktree's toplevel.
3525
+ // For linked worktrees, --git-common-dir points to the shared .git,
3526
+ // and the main worktree's toplevel is one level up from that (non-bare)
3527
+ // or the dir itself (bare). We use git's worktree list to get the
3528
+ // main worktree path reliably.
3529
+ let mainRepoDir = options.mainRepo
3530
+ if (!mainRepoDir) {
3531
+ try {
3532
+ // `git worktree list --porcelain` first line is always the main worktree
3533
+ const { stdout } = await execAsync(
3534
+ `git -C "${worktreeDir}" worktree list --porcelain`,
3535
+ )
3536
+ const firstLine = stdout.split('\n')[0] || ''
3537
+ // Format: "worktree /path/to/main"
3538
+ mainRepoDir = firstLine.replace(/^worktree\s+/, '').trim()
3539
+ } catch {
3540
+ // Fallback: derive from git common dir
3541
+ const { stdout: commonDir } = await execAsync(
3542
+ `git -C "${worktreeDir}" rev-parse --git-common-dir`,
3543
+ )
3544
+ const resolved = path.isAbsolute(commonDir.trim())
3545
+ ? commonDir.trim()
3546
+ : path.resolve(worktreeDir, commonDir.trim())
3547
+ mainRepoDir = path.dirname(resolved)
3548
+ }
3549
+ }
3550
+
3551
+ // Auto-detect branch name if not provided
3552
+ let worktreeName = options.name
3553
+ if (!worktreeName) {
3554
+ try {
3555
+ const { stdout } = await execAsync(
3556
+ `git -C "${worktreeDir}" symbolic-ref --short HEAD`,
3557
+ )
3558
+ worktreeName = stdout.trim()
3559
+ } catch {
3560
+ worktreeName = path.basename(worktreeDir)
3561
+ }
3562
+ }
3563
+
3564
+ cliLogger.log(`Worktree: ${worktreeDir}`)
3565
+ cliLogger.log(`Main repo: ${mainRepoDir}`)
3566
+ cliLogger.log(`Branch: ${worktreeName}`)
3567
+
3568
+ const { RebaseConflictError } = await import('./errors.js')
3569
+
3570
+ const result = await mergeWorktree({
3571
+ worktreeDir,
3572
+ mainRepoDir,
3573
+ worktreeName,
3574
+ onProgress: (msg) => {
3575
+ cliLogger.log(msg)
3576
+ },
3577
+ })
3578
+
3579
+ if (result instanceof Error) {
3580
+ cliLogger.error(`Merge failed: ${result.message}`)
3581
+ if (result instanceof RebaseConflictError) {
3582
+ cliLogger.log(
3583
+ 'Resolve the rebase conflicts, then run this command again.',
3584
+ )
3585
+ }
3586
+ process.exit(1)
3587
+ }
3588
+
3589
+ cliLogger.log(
3590
+ `Merged ${result.branchName} into ${result.defaultBranch} @ ${result.shortSha} (${result.commitCount} commit${result.commitCount === 1 ? '' : 's'})`,
3591
+ )
3592
+ process.exit(0)
3593
+ } catch (error) {
3594
+ cliLogger.error(
3595
+ 'Merge failed:',
3596
+ error instanceof Error ? error.message : String(error),
3597
+ )
3598
+ process.exit(EXIT_NO_RESTART)
3599
+ }
3600
+ },
3601
+ )
3602
+
3603
+ cli.version(getCurrentVersion())
3604
+ cli.help()
3605
+ cli.parse()