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