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