discoclaw 0.1.0

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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Interactive init wizard for discoclaw.
3
+ * Invoked by `discoclaw init` to guide a first-time user through setup.
4
+ * Creates a .env file and scaffolds a workspace/ directory in the current
5
+ * working directory.
6
+ */
7
+ import * as readline from 'node:readline/promises';
8
+ import { stdin as input, stdout as output } from 'node:process';
9
+ import { execFileSync } from 'node:child_process';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { validateDiscordToken, validateSnowflake, validateSnowflakes } from '../validate.js';
13
+ import { ensureWorkspaceBootstrapFiles } from '../workspace-bootstrap.js';
14
+ // ── helpers ───────────────────────────────────────────────────────────────────
15
+ function which(bin) {
16
+ try {
17
+ const finder = process.platform === 'win32' ? 'where' : 'which';
18
+ execFileSync(finder, [bin], { stdio: 'pipe' });
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export function backupFileName(now = new Date()) {
26
+ const ts = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '');
27
+ return `.env.backup.${ts}`;
28
+ }
29
+ export function buildEnvContent(vals, now = new Date()) {
30
+ const lines = [
31
+ '# Discoclaw — generated by discoclaw init',
32
+ `# Created: ${now.toISOString()}`,
33
+ '',
34
+ ];
35
+ lines.push('# REQUIRED');
36
+ lines.push(`DISCORD_TOKEN=${vals.DISCORD_TOKEN ?? ''}`);
37
+ lines.push(`DISCORD_ALLOW_USER_IDS=${vals.DISCORD_ALLOW_USER_IDS ?? ''}`);
38
+ lines.push(`DISCOCLAW_TASKS_FORUM=${vals.DISCOCLAW_TASKS_FORUM ?? ''}`);
39
+ lines.push(`DISCOCLAW_CRON_FORUM=${vals.DISCOCLAW_CRON_FORUM ?? ''}`);
40
+ lines.push('');
41
+ if (vals.PRIMARY_RUNTIME) {
42
+ const providerSpecificKeys = [
43
+ 'GEMINI_BIN',
44
+ 'GEMINI_MODEL',
45
+ 'OPENAI_API_KEY',
46
+ 'CODEX_BIN',
47
+ 'CODEX_MODEL',
48
+ 'CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX',
49
+ 'CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS',
50
+ 'CLAUDE_OUTPUT_FORMAT',
51
+ 'OPENROUTER_API_KEY',
52
+ 'OPENROUTER_BASE_URL',
53
+ 'OPENROUTER_MODEL',
54
+ ];
55
+ lines.push('# PROVIDER');
56
+ lines.push(`PRIMARY_RUNTIME=${vals.PRIMARY_RUNTIME}`);
57
+ for (const k of providerSpecificKeys) {
58
+ if (vals[k])
59
+ lines.push(`${k}=${vals[k]}`);
60
+ }
61
+ lines.push('');
62
+ if (vals.DISCORD_GUILD_ID) {
63
+ lines.push('# CORE');
64
+ lines.push(`DISCORD_GUILD_ID=${vals.DISCORD_GUILD_ID}`);
65
+ lines.push('');
66
+ }
67
+ }
68
+ const optionalKeys = ['DISCOCLAW_DISCORD_ACTIONS', 'DISCOCLAW_STATUS_CHANNEL'];
69
+ const hasOptional = optionalKeys.some((k) => vals[k]);
70
+ if (hasOptional) {
71
+ lines.push('# OPTIONAL');
72
+ for (const k of optionalKeys) {
73
+ if (vals[k])
74
+ lines.push(`${k}=${vals[k]}`);
75
+ }
76
+ lines.push('');
77
+ }
78
+ lines.push('# For all options, see .env.example.full');
79
+ lines.push('');
80
+ return lines.join('\n');
81
+ }
82
+ export function selectDefaultProvider(detected) {
83
+ if (detected.includes('claude'))
84
+ return '1';
85
+ if (detected.includes('gemini'))
86
+ return '2';
87
+ if (detected.includes('codex'))
88
+ return '4';
89
+ return '1';
90
+ }
91
+ // ── main wizard ───────────────────────────────────────────────────────────────
92
+ export async function runInitWizard() {
93
+ if (!input.isTTY) {
94
+ console.error('discoclaw init requires an interactive terminal.\n');
95
+ process.exit(1);
96
+ }
97
+ const cwd = process.cwd();
98
+ const envPath = path.join(cwd, '.env');
99
+ let rl = null;
100
+ let canceled = false;
101
+ let completed = false;
102
+ function cleanup() {
103
+ canceled = true;
104
+ try {
105
+ fs.unlinkSync(path.join(cwd, '.env.tmp'));
106
+ }
107
+ catch {
108
+ /* ignore */
109
+ }
110
+ if (rl) {
111
+ const toClose = rl;
112
+ rl = null;
113
+ toClose.close();
114
+ }
115
+ console.log('\n\nSetup canceled.\n');
116
+ process.exit(1);
117
+ }
118
+ process.on('SIGINT', cleanup);
119
+ process.on('SIGTERM', cleanup);
120
+ rl = readline.createInterface({ input, output });
121
+ rl.on('close', () => {
122
+ if (!canceled && !completed)
123
+ cleanup();
124
+ });
125
+ async function ask(prompt) {
126
+ if (canceled || !rl)
127
+ return '';
128
+ return rl.question(prompt);
129
+ }
130
+ async function askValidated(prompt, validate) {
131
+ while (true) {
132
+ if (canceled)
133
+ return '';
134
+ const val = await ask(prompt);
135
+ const err = validate(val.trim());
136
+ if (!err)
137
+ return val.trim();
138
+ console.log(` Error: ${err}. Try again.\n`);
139
+ }
140
+ }
141
+ async function askOptional(prompt, validate) {
142
+ while (true) {
143
+ if (canceled)
144
+ return '';
145
+ const val = await ask(prompt);
146
+ if (!val.trim())
147
+ return '';
148
+ const err = validate(val.trim());
149
+ if (!err)
150
+ return val.trim();
151
+ console.log(` Error: ${err}. Try again.\n`);
152
+ }
153
+ }
154
+ // ── Welcome ──────────────────────────────────────────────────────────────
155
+ console.log(`\nDiscoclaw Init\n==============\n` +
156
+ `This wizard creates a .env file and workspace/ directory in:\n ${cwd}\n`);
157
+ // ── Discord bot guidance ──────────────────────────────────────────────────
158
+ console.log(`Discord Bot Setup\n-----------------\n` +
159
+ `If you haven't created a Discord bot yet, follow these steps:\n\n` +
160
+ ` 1. Go to https://discord.com/developers/applications\n` +
161
+ ` 2. Click "New Application" and give it a name (e.g. "DiscoClaw").\n` +
162
+ ` 3. Open the "Bot" tab and click "Add Bot".\n` +
163
+ ` 4. Enable "Message Content Intent" under Privileged Gateway Intents.\n` +
164
+ ` 5. Click "Reset Token", copy it — you'll enter it below.\n` +
165
+ ` 6. Invite your bot: Bot tab → OAuth2 → URL Generator\n` +
166
+ ` Scopes: bot Permissions: Send Messages, Read Message History\n` +
167
+ ` Open the generated URL and select your server.\n\n` +
168
+ `Already have a bot? Just press Enter.\n`);
169
+ await ask('Press Enter to continue... ');
170
+ // ── Check existing .env ───────────────────────────────────────────────────
171
+ if (fs.existsSync(envPath)) {
172
+ const existing = fs.readFileSync(envPath, 'utf8');
173
+ const tokenMatch = existing.match(/^DISCORD_TOKEN=(.*)$/m);
174
+ const idsMatch = existing.match(/^DISCORD_ALLOW_USER_IDS=(.*)$/m);
175
+ console.log('\nExisting .env detected:');
176
+ if (tokenMatch?.[1]) {
177
+ const t = tokenMatch[1].trim();
178
+ console.log(` DISCORD_TOKEN = ${t.slice(0, 8)}...(masked)`);
179
+ }
180
+ else {
181
+ console.log(' DISCORD_TOKEN = (not set)');
182
+ }
183
+ if (idsMatch?.[1]) {
184
+ const ids = idsMatch[1].trim().split(/[,\s]+/).filter(Boolean);
185
+ const masked = ids.map((id) => (id.length > 6 ? `${id.slice(0, 3)}...${id.slice(-3)}` : '***'));
186
+ console.log(` DISCORD_ALLOW_USER_IDS = ${masked.join(', ')}`);
187
+ }
188
+ else {
189
+ console.log(' DISCORD_ALLOW_USER_IDS = (not set)');
190
+ }
191
+ console.log('');
192
+ const overwrite = await ask('Overwrite with fresh config? [y/N] ');
193
+ if (overwrite.toLowerCase() !== 'y') {
194
+ console.log('Run discoclaw init after removing .env to reconfigure.\n');
195
+ completed = true;
196
+ rl.close();
197
+ return;
198
+ }
199
+ const bkName = backupFileName();
200
+ const backupPath = path.join(cwd, bkName);
201
+ fs.copyFileSync(envPath, backupPath);
202
+ console.log(` Backed up to ${bkName}\n`);
203
+ }
204
+ // ── Required values ───────────────────────────────────────────────────────
205
+ const values = {};
206
+ values.DISCORD_TOKEN = await askValidated('Discord bot token: ', (val) => {
207
+ const r = validateDiscordToken(val);
208
+ return r.valid ? null : (r.reason ?? 'Invalid token format');
209
+ });
210
+ values.DISCORD_ALLOW_USER_IDS = await askValidated('Allowed user IDs (comma-separated): ', (val) => {
211
+ if (!val.trim())
212
+ return 'At least one user ID is required';
213
+ const r = validateSnowflakes(val);
214
+ if (!r.valid && r.invalidIds.length > 0)
215
+ return `Invalid IDs: ${r.invalidIds.join(', ')}`;
216
+ if (!r.valid)
217
+ return 'At least one valid snowflake ID is required';
218
+ return null;
219
+ });
220
+ values.DISCOCLAW_TASKS_FORUM = await askValidated('Tasks forum channel ID (required): ', (val) => (validateSnowflake(val) ? null : 'Must be a 17-20 digit number'));
221
+ values.DISCOCLAW_CRON_FORUM = await askValidated('Automations forum channel ID (required): ', (val) => (validateSnowflake(val) ? null : 'Must be a 17-20 digit number'));
222
+ // ── Runtime detection ─────────────────────────────────────────────────────
223
+ const detected = [];
224
+ if (which('claude'))
225
+ detected.push('claude');
226
+ if (which('gemini'))
227
+ detected.push('gemini');
228
+ if (which('codex'))
229
+ detected.push('codex');
230
+ if (detected.length > 0) {
231
+ console.log(`\nDetected runtimes: ${detected.join(', ')}`);
232
+ }
233
+ else {
234
+ console.log('\nNo AI runtimes detected in PATH.');
235
+ console.log(' → Install Claude CLI: https://docs.anthropic.com/en/docs/claude-code');
236
+ }
237
+ // ── Provider selection ────────────────────────────────────────────────────
238
+ console.log('\nSelect your AI provider:');
239
+ console.log(' 1) Claude' + (detected.includes('claude') ? ' (detected)' : ''));
240
+ console.log(' 2) Gemini' + (detected.includes('gemini') ? ' (detected)' : ''));
241
+ console.log(' 3) OpenAI');
242
+ console.log(' 4) Codex' + (detected.includes('codex') ? ' (detected)' : ''));
243
+ console.log(' 5) OpenRouter');
244
+ const defaultProvider = selectDefaultProvider(detected);
245
+ const providerChoice = await askValidated(`Provider [1-5, default: ${defaultProvider}]: `, (val) => {
246
+ const effective = val || defaultProvider;
247
+ return ['1', '2', '3', '4', '5'].includes(effective) ? null : 'Enter 1, 2, 3, 4, or 5';
248
+ });
249
+ const finalChoice = providerChoice || defaultProvider;
250
+ if (finalChoice === '1') {
251
+ values.PRIMARY_RUNTIME = 'claude';
252
+ const skipPerms = await ask('Enable CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS? (required for headless operation) [Y/n] ');
253
+ if (skipPerms.toLowerCase() !== 'n') {
254
+ values.CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS = '1';
255
+ }
256
+ const streamJson = await ask('Use stream-json output format? (smoother streaming) [Y/n] ');
257
+ if (streamJson.toLowerCase() !== 'n') {
258
+ values.CLAUDE_OUTPUT_FORMAT = 'stream-json';
259
+ }
260
+ }
261
+ else if (finalChoice === '2') {
262
+ values.PRIMARY_RUNTIME = 'gemini';
263
+ console.log(' Note: auth is handled by the gemini binary itself (run `gemini` to authenticate).');
264
+ const gemBin = await askOptional('Gemini binary path [default: gemini]: ', () => null);
265
+ values.GEMINI_BIN = gemBin || 'gemini';
266
+ const gemModel = await askOptional('Gemini model [default: gemini-2.5-pro]: ', () => null);
267
+ values.GEMINI_MODEL = gemModel || 'gemini-2.5-pro';
268
+ }
269
+ else if (finalChoice === '3') {
270
+ values.PRIMARY_RUNTIME = 'openai';
271
+ console.log(' Note: the OpenAI adapter is HTTP-only.');
272
+ values.OPENAI_API_KEY = await askValidated('OpenAI API key: ', (val) => (val ? null : 'API key is required'));
273
+ }
274
+ else if (finalChoice === '4') {
275
+ values.PRIMARY_RUNTIME = 'codex';
276
+ const codexBin = await askOptional('Codex binary path [leave empty to use PATH]: ', () => null);
277
+ if (codexBin)
278
+ values.CODEX_BIN = codexBin;
279
+ const codexModel = await askOptional('Codex model [leave empty for default]: ', () => null);
280
+ if (codexModel)
281
+ values.CODEX_MODEL = codexModel;
282
+ const bypassApprovals = await ask('Enable CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX? [y/N] ');
283
+ if (bypassApprovals.toLowerCase() === 'y') {
284
+ values.CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX = '1';
285
+ }
286
+ }
287
+ else if (finalChoice === '5') {
288
+ values.PRIMARY_RUNTIME = 'openrouter';
289
+ console.log(' Note: the OpenRouter adapter is HTTP-only.');
290
+ values.OPENROUTER_API_KEY = await askValidated('OpenRouter API key: ', (val) => (val ? null : 'API key is required'));
291
+ const orBaseUrl = await askOptional('OpenRouter base URL [leave empty for default]: ', () => null);
292
+ if (orBaseUrl)
293
+ values.OPENROUTER_BASE_URL = orBaseUrl;
294
+ const orModel = await askOptional('OpenRouter model [default: anthropic/claude-sonnet-4]: ', () => null);
295
+ values.OPENROUTER_MODEL = orModel || 'anthropic/claude-sonnet-4';
296
+ }
297
+ // ── Recommended settings ──────────────────────────────────────────────────
298
+ const configRecommended = await ask('\nConfigure recommended settings? [Y/n] ');
299
+ if (configRecommended.toLowerCase() !== 'n') {
300
+ const guildId = await askOptional('Discord guild (server) ID [leave empty to skip]: ', (val) => {
301
+ if (!val)
302
+ return null;
303
+ return validateSnowflake(val) ? null : 'Must be a 17-20 digit number';
304
+ });
305
+ if (guildId)
306
+ values.DISCORD_GUILD_ID = guildId;
307
+ }
308
+ // ── Optional features ─────────────────────────────────────────────────────
309
+ const configOptional = await ask('\nConfigure optional features? [y/N] ');
310
+ if (configOptional.toLowerCase() === 'y') {
311
+ const actions = await ask('Enable Discord Actions? (lets the AI manage your server) [Y/n] ');
312
+ if (actions.toLowerCase() === 'n') {
313
+ values.DISCOCLAW_DISCORD_ACTIONS = '0';
314
+ }
315
+ else {
316
+ values.DISCOCLAW_DISCORD_ACTIONS = '1';
317
+ }
318
+ const statusChannel = await askOptional('Status channel ID or name [leave empty to skip]: ', () => null);
319
+ if (statusChannel)
320
+ values.DISCOCLAW_STATUS_CHANNEL = statusChannel;
321
+ }
322
+ // ── Write .env ────────────────────────────────────────────────────────────
323
+ const envContent = buildEnvContent(values);
324
+ const tmpPath = path.join(cwd, '.env.tmp');
325
+ fs.writeFileSync(tmpPath, envContent, 'utf8');
326
+ fs.renameSync(tmpPath, envPath);
327
+ console.log('\n.env written successfully.\n');
328
+ // ── Scaffold workspace ────────────────────────────────────────────────────
329
+ const workspaceCwd = path.join(cwd, 'workspace');
330
+ console.log(`Scaffolding workspace at ${workspaceCwd}...`);
331
+ const scaffolded = await ensureWorkspaceBootstrapFiles(workspaceCwd);
332
+ if (scaffolded.length > 0) {
333
+ console.log(` Created: ${scaffolded.join(', ')}`);
334
+ }
335
+ else {
336
+ console.log(' Workspace already set up — no files changed.');
337
+ }
338
+ console.log('');
339
+ // ── Next steps ────────────────────────────────────────────────────────────
340
+ let daemonHint;
341
+ if (process.platform === 'darwin') {
342
+ daemonHint = 'discoclaw install-daemon # sets up a launchd service';
343
+ }
344
+ else if (process.platform === 'win32') {
345
+ daemonHint = 'Run `discoclaw` directly or use a process manager (e.g. PM2).';
346
+ }
347
+ else {
348
+ daemonHint = 'discoclaw install-daemon # sets up a systemd user service';
349
+ }
350
+ console.log('Configuration complete!\n');
351
+ console.log('Next steps:');
352
+ if (values.PRIMARY_RUNTIME === 'claude') {
353
+ console.log(` ${daemonHint}`);
354
+ }
355
+ else if (values.PRIMARY_RUNTIME === 'gemini') {
356
+ console.log(' 1. Authenticate: run `gemini` and follow the prompts.');
357
+ console.log(` 2. ${daemonHint}`);
358
+ }
359
+ else if (values.PRIMARY_RUNTIME === 'openai') {
360
+ console.log(' 1. Verify your OPENAI_API_KEY is correct.');
361
+ console.log(` 2. ${daemonHint}`);
362
+ }
363
+ else if (values.PRIMARY_RUNTIME === 'codex') {
364
+ console.log(' 1. Ensure the Codex binary is installed and accessible.');
365
+ console.log(` 2. ${daemonHint}`);
366
+ }
367
+ else if (values.PRIMARY_RUNTIME === 'openrouter') {
368
+ console.log(' 1. Verify your OPENROUTER_API_KEY is correct.');
369
+ console.log(` 2. ${daemonHint}`);
370
+ }
371
+ console.log('');
372
+ completed = true;
373
+ rl.close();
374
+ }
@@ -0,0 +1,191 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ vi.mock('node:readline/promises', () => ({
6
+ createInterface: vi.fn(),
7
+ }));
8
+ vi.mock('node:child_process', () => ({
9
+ execFileSync: vi.fn(),
10
+ }));
11
+ vi.mock('../workspace-bootstrap.js', () => ({
12
+ ensureWorkspaceBootstrapFiles: vi.fn(async () => []),
13
+ }));
14
+ import { createInterface } from 'node:readline/promises';
15
+ import { execFileSync } from 'node:child_process';
16
+ import { ensureWorkspaceBootstrapFiles } from '../workspace-bootstrap.js';
17
+ import { backupFileName, buildEnvContent, runInitWizard, selectDefaultProvider } from './init-wizard.js';
18
+ const initialSigintListeners = new Set(process.listeners('SIGINT'));
19
+ const initialSigtermListeners = new Set(process.listeners('SIGTERM'));
20
+ const originalIsTTY = process.stdin.isTTY;
21
+ function makeReadline(answers) {
22
+ let closeHandler;
23
+ return {
24
+ question: vi.fn(async () => answers.shift() ?? ''),
25
+ close: vi.fn(() => {
26
+ closeHandler?.();
27
+ }),
28
+ on: vi.fn((event, cb) => {
29
+ if (event === 'close')
30
+ closeHandler = cb;
31
+ }),
32
+ };
33
+ }
34
+ describe('init wizard helpers', () => {
35
+ it('formats backup filenames from timestamps', () => {
36
+ const name = backupFileName(new Date('2026-02-21T18:45:12.999Z'));
37
+ expect(name).toBe('.env.backup.20260221T184512');
38
+ });
39
+ it('builds env content with provider/core/optional sections', () => {
40
+ const content = buildEnvContent({
41
+ DISCORD_TOKEN: 'a.b.c',
42
+ DISCORD_ALLOW_USER_IDS: '1000000000000000001',
43
+ DISCOCLAW_TASKS_FORUM: '1000000000000000002',
44
+ DISCOCLAW_CRON_FORUM: '1000000000000000003',
45
+ PRIMARY_RUNTIME: 'claude',
46
+ CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS: '1',
47
+ CLAUDE_OUTPUT_FORMAT: 'stream-json',
48
+ DISCORD_GUILD_ID: '1000000000000000004',
49
+ DISCOCLAW_DISCORD_ACTIONS: '1',
50
+ }, new Date('2026-02-21T00:00:00.000Z'));
51
+ expect(content).toContain('# REQUIRED');
52
+ expect(content).toContain('PRIMARY_RUNTIME=claude');
53
+ expect(content).toContain('CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1');
54
+ expect(content).toContain('# CORE');
55
+ expect(content).toContain('DISCORD_GUILD_ID=1000000000000000004');
56
+ expect(content).toContain('# OPTIONAL');
57
+ expect(content).toContain('DISCOCLAW_DISCORD_ACTIONS=1');
58
+ });
59
+ it('selects provider defaults in expected precedence order', () => {
60
+ expect(selectDefaultProvider(['codex'])).toBe('4');
61
+ expect(selectDefaultProvider(['gemini', 'codex'])).toBe('2');
62
+ expect(selectDefaultProvider(['claude', 'gemini', 'codex'])).toBe('1');
63
+ expect(selectDefaultProvider([])).toBe('1');
64
+ });
65
+ it('returns default provider 1 when no OpenRouter runtime is detected (HTTP-only, no binary)', () => {
66
+ // OpenRouter has no CLI binary so detection never adds it to the list
67
+ expect(selectDefaultProvider([])).toBe('1');
68
+ expect(selectDefaultProvider(['openrouter'])).toBe('1');
69
+ });
70
+ it('includes OpenRouter keys in generated env content', () => {
71
+ const content = buildEnvContent({
72
+ DISCORD_TOKEN: 'a.b.c',
73
+ DISCORD_ALLOW_USER_IDS: '1000000000000000001',
74
+ DISCOCLAW_TASKS_FORUM: '1000000000000000002',
75
+ DISCOCLAW_CRON_FORUM: '1000000000000000003',
76
+ PRIMARY_RUNTIME: 'openrouter',
77
+ OPENROUTER_API_KEY: 'sk-or-test-key',
78
+ OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
79
+ OPENROUTER_MODEL: 'anthropic/claude-sonnet-4',
80
+ }, new Date('2026-02-22T00:00:00.000Z'));
81
+ expect(content).toContain('PRIMARY_RUNTIME=openrouter');
82
+ expect(content).toContain('OPENROUTER_API_KEY=sk-or-test-key');
83
+ expect(content).toContain('OPENROUTER_BASE_URL=https://openrouter.ai/api/v1');
84
+ expect(content).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4');
85
+ });
86
+ });
87
+ describe('runInitWizard', () => {
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ process.stdin.isTTY = true;
91
+ });
92
+ afterEach(() => {
93
+ process.stdin.isTTY = originalIsTTY;
94
+ for (const listener of process.listeners('SIGINT')) {
95
+ if (!initialSigintListeners.has(listener))
96
+ process.removeListener('SIGINT', listener);
97
+ }
98
+ for (const listener of process.listeners('SIGTERM')) {
99
+ if (!initialSigtermListeners.has(listener))
100
+ process.removeListener('SIGTERM', listener);
101
+ }
102
+ vi.restoreAllMocks();
103
+ });
104
+ it('rejects non-interactive terminals', async () => {
105
+ process.stdin.isTTY = false;
106
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
107
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
108
+ throw new Error(`exit:${code ?? 0}`);
109
+ }));
110
+ await expect(runInitWizard()).rejects.toThrow('exit:1');
111
+ expect(errSpy).toHaveBeenCalledWith('discoclaw init requires an interactive terminal.\n');
112
+ expect(exitSpy).toHaveBeenCalledWith(1);
113
+ expect(vi.mocked(createInterface)).not.toHaveBeenCalled();
114
+ });
115
+ it('backs up an existing .env before overwrite and writes new config', async () => {
116
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
117
+ const previousCwd = process.cwd();
118
+ const oldEnv = 'DISCORD_TOKEN=old.token.value\nDISCORD_ALLOW_USER_IDS=111111111111111111\n';
119
+ const answers = [
120
+ '', // Press Enter to continue
121
+ 'y', // Overwrite existing .env
122
+ 'a.b.c', // DISCORD_TOKEN
123
+ '1000000000000000001', // DISCORD_ALLOW_USER_IDS
124
+ '1000000000000000002', // DISCOCLAW_TASKS_FORUM
125
+ '1000000000000000003', // DISCOCLAW_CRON_FORUM
126
+ '', // provider selection -> default (Claude)
127
+ '', // enable skip permissions
128
+ '', // enable stream-json
129
+ 'n', // configure recommended settings
130
+ 'n', // configure optional features
131
+ ];
132
+ fs.writeFileSync(path.join(tmpDir, '.env'), oldEnv, 'utf8');
133
+ process.chdir(tmpDir);
134
+ vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
135
+ vi.mocked(execFileSync).mockImplementation(() => {
136
+ throw new Error('binary not found');
137
+ });
138
+ vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
139
+ vi.spyOn(console, 'log').mockImplementation(() => { });
140
+ try {
141
+ await runInitWizard();
142
+ }
143
+ finally {
144
+ process.chdir(previousCwd);
145
+ }
146
+ const backupFiles = fs.readdirSync(tmpDir).filter((name) => name.startsWith('.env.backup.'));
147
+ expect(backupFiles).toHaveLength(1);
148
+ expect(fs.readFileSync(path.join(tmpDir, backupFiles[0]), 'utf8')).toBe(oldEnv);
149
+ const newEnv = fs.readFileSync(path.join(tmpDir, '.env'), 'utf8');
150
+ expect(newEnv).toContain('DISCORD_TOKEN=a.b.c');
151
+ expect(newEnv).toContain('PRIMARY_RUNTIME=claude');
152
+ expect(newEnv).toContain('CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1');
153
+ expect(newEnv).toContain('CLAUDE_OUTPUT_FORMAT=stream-json');
154
+ expect(ensureWorkspaceBootstrapFiles).toHaveBeenCalledWith(path.join(tmpDir, 'workspace'));
155
+ });
156
+ it('writes openrouter config when provider 5 is selected', async () => {
157
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
158
+ const previousCwd = process.cwd();
159
+ const answers = [
160
+ '', // Press Enter to continue
161
+ // no existing .env
162
+ 'a.b.c', // DISCORD_TOKEN
163
+ '1000000000000000001', // DISCORD_ALLOW_USER_IDS
164
+ '1000000000000000002', // DISCOCLAW_TASKS_FORUM
165
+ '1000000000000000003', // DISCOCLAW_CRON_FORUM
166
+ '5', // provider selection -> OpenRouter
167
+ 'sk-or-test-key', // OPENROUTER_API_KEY
168
+ '', // OPENROUTER_BASE_URL (optional, skip)
169
+ '', // OPENROUTER_MODEL (optional, use default)
170
+ 'n', // configure recommended settings
171
+ 'n', // configure optional features
172
+ ];
173
+ process.chdir(tmpDir);
174
+ vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
175
+ vi.mocked(execFileSync).mockImplementation(() => {
176
+ throw new Error('binary not found');
177
+ });
178
+ vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
179
+ vi.spyOn(console, 'log').mockImplementation(() => { });
180
+ try {
181
+ await runInitWizard();
182
+ }
183
+ finally {
184
+ process.chdir(previousCwd);
185
+ }
186
+ const newEnv = fs.readFileSync(path.join(tmpDir, '.env'), 'utf8');
187
+ expect(newEnv).toContain('PRIMARY_RUNTIME=openrouter');
188
+ expect(newEnv).toContain('OPENROUTER_API_KEY=sk-or-test-key');
189
+ expect(newEnv).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4');
190
+ });
191
+ });