kimaki 0.19.0 → 0.20.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.
@@ -190,23 +190,23 @@ function getTokenTotal(tokens) {
190
190
  tokens.cache.read +
191
191
  tokens.cache.write);
192
192
  }
193
+ /**
194
+ * Built-in read-only tools that are hidden in default verbosity mode.
195
+ * Any tool NOT in this list is considered "essential" and shown,
196
+ * which means custom tools, MCP tools, and plugin tools are visible by default.
197
+ */
198
+ const HIDDEN_READONLY_TOOLS = [
199
+ 'read',
200
+ 'glob',
201
+ 'grep',
202
+ 'describe-media',
203
+ 'todoread',
204
+ ];
193
205
  /** Check if a tool part is "essential" (shown in text-and-essential-tools mode). */
194
206
  export function isEssentialToolName(toolName) {
195
- const essentialTools = [
196
- 'edit',
197
- 'write',
198
- 'apply_patch',
199
- 'bash',
200
- 'webfetch',
201
- 'websearch',
202
- 'googlesearch',
203
- 'codesearch',
204
- 'task',
205
- 'todowrite',
206
- 'skill',
207
- ];
208
- // Also match any MCP tool that contains these names
209
- return essentialTools.some((name) => {
207
+ // Hide known read-only built-in tools; show everything else
208
+ // (custom tools, MCP tools, plugin tools are visible by default)
209
+ return !HIDDEN_READONLY_TOOLS.some((name) => {
210
210
  return toolName === name || toolName.endsWith(`_${name}`);
211
211
  });
212
212
  }
@@ -2062,6 +2062,15 @@ export class ThreadSessionRuntime {
2062
2062
  skippedBySessionGuard = true;
2063
2063
  return;
2064
2064
  }
2065
+ // Context-only messages (noReply) should not create a new session.
2066
+ // If there is no existing session, silently skip.
2067
+ if (input.noReply) {
2068
+ const existingSessionId = this.state?.sessionId || await getThreadSession(this.thread.id) || undefined;
2069
+ if (!existingSessionId) {
2070
+ logger.log(`[INGRESS] Skipping noReply message for thread ${this.threadId}: no existing session`);
2071
+ return;
2072
+ }
2073
+ }
2065
2074
  // Helper: stop typing and drain queued local messages on error.
2066
2075
  const cleanupOnError = async (errorMessage) => {
2067
2076
  this.stopTyping();
@@ -2269,6 +2278,7 @@ export class ThreadSessionRuntime {
2269
2278
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
2270
2279
  ...(modelField ? { model: modelField } : {}),
2271
2280
  ...variantField,
2281
+ ...(input.noReply ? { noReply: true } : {}),
2272
2282
  };
2273
2283
  const promptResult = await getClient().session.promptAsync(request)
2274
2284
  .catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }));
@@ -2284,7 +2294,10 @@ export class ThreadSessionRuntime {
2284
2294
  return;
2285
2295
  }
2286
2296
  logger.log(`[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`);
2287
- this.markQueueDispatchBusy(session.id);
2297
+ // noReply messages don't trigger the agent loop, so don't mark as busy
2298
+ if (!input.noReply) {
2299
+ this.markQueueDispatchBusy(session.id);
2300
+ }
2288
2301
  });
2289
2302
  if (skippedBySessionGuard) {
2290
2303
  return { queued: false };
@@ -2426,9 +2439,17 @@ export class ThreadSessionRuntime {
2426
2439
  // Route with the resolved mode through normal paths.
2427
2440
  // Await the enqueue so session state (ensureSession, setThreadSession)
2428
2441
  // is persisted before the next message's preprocessing reads it.
2429
- const enqueueResult = resolvedInput.mode === 'local-queue' || resolvedInput.command
2430
- ? await this.enqueueViaLocalQueue(resolvedInput)
2431
- : await this.submitViaOpencodeQueue(resolvedInput);
2442
+ // noReply messages always go through the opencode path so the flag
2443
+ // reaches promptAsync; local queue doesn't support noReply.
2444
+ const enqueueResult = resolvedInput.noReply
2445
+ ? await this.submitViaOpencodeQueue({
2446
+ ...resolvedInput,
2447
+ mode: 'opencode',
2448
+ command: undefined,
2449
+ })
2450
+ : (resolvedInput.mode === 'local-queue' || resolvedInput.command)
2451
+ ? await this.enqueueViaLocalQueue(resolvedInput)
2452
+ : await this.submitViaOpencodeQueue(resolvedInput);
2432
2453
  resolveOuter(enqueueResult);
2433
2454
  }
2434
2455
  catch (err) {
package/dist/store.js CHANGED
@@ -14,6 +14,7 @@ export const store = createStore(() => ({
14
14
  allowedMentions: ['users'],
15
15
  allowAllUsers: false,
16
16
  permissionTimeoutMs: 10 * 60 * 1000,
17
+ useWorktrees: false,
17
18
  autoUpgradeEnabled: true,
18
19
  syncEnabled: true,
19
20
  discordBaseUrl: 'https://discord.com',
@@ -279,10 +279,22 @@ The user is reading your messages from inside Discord, via kimaki.dev
279
279
 
280
280
  ## bash tool
281
281
 
282
- When calling the bash tool, always include a boolean field \`hasSideEffect\`.
283
- Set \`hasSideEffect: true\` for any command that writes files, modifies repo state, installs packages, changes config, runs scripts that mutate state, or triggers external effects.
284
- Set \`hasSideEffect: false\` for read-only commands (e.g. ls, tree, cat, rg, grep, git status, git diff, pwd, whoami, etc).
285
- This is required to distinguish essential bash calls from read-only ones in low-verbosity mode.
282
+ When calling the bash tool, always include these extra fields alongside \`command\`:
283
+
284
+ \`\`\`ts
285
+ interface BashToolInput {
286
+ command: string
287
+ /** Short 5-10 word summary of what this command does */
288
+ description: string
289
+ /** true if the command writes files, modifies state, installs packages, or triggers external effects */
290
+ hasSideEffect: boolean
291
+ workdir?: string
292
+ timeout?: number
293
+ }
294
+ \`\`\`
295
+
296
+ \`description\` is shown to the user in Discord as a summary of the bash call.
297
+ \`hasSideEffect\` distinguishes essential bash calls from read-only ones in low-verbosity mode.
286
298
 
287
299
  Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}${threadId ? `\nYour current Discord thread ID is: ${threadId}` : ''}${guildId ? `\nYour current Discord guild ID is: ${guildId}` : ''}
288
300
 
@@ -34,10 +34,22 @@ describe('system-message', () => {
34
34
 
35
35
  ## bash tool
36
36
 
37
- When calling the bash tool, always include a boolean field \`hasSideEffect\`.
38
- Set \`hasSideEffect: true\` for any command that writes files, modifies repo state, installs packages, changes config, runs scripts that mutate state, or triggers external effects.
39
- Set \`hasSideEffect: false\` for read-only commands (e.g. ls, tree, cat, rg, grep, git status, git diff, pwd, whoami, etc).
40
- This is required to distinguish essential bash calls from read-only ones in low-verbosity mode.
37
+ When calling the bash tool, always include these extra fields alongside \`command\`:
38
+
39
+ \`\`\`ts
40
+ interface BashToolInput {
41
+ command: string
42
+ /** Short 5-10 word summary of what this command does */
43
+ description: string
44
+ /** true if the command writes files, modifies state, installs packages, or triggers external effects */
45
+ hasSideEffect: boolean
46
+ workdir?: string
47
+ timeout?: number
48
+ }
49
+ \`\`\`
50
+
51
+ \`description\` is shown to the user in Discord as a summary of the bash call.
52
+ \`hasSideEffect\` distinguishes essential bash calls from read-only ones in low-verbosity mode.
41
53
 
42
54
  Your current OpenCode session ID is: ses_123
43
55
  Your current Discord channel ID is: chan_123
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.19.0",
5
+ "version": "0.20.1",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -25,8 +25,8 @@
25
25
  "tsx": "^4.20.5",
26
26
  "undici": "^8.0.2",
27
27
  "discord-digital-twin": "^0.1.0",
28
- "opencode-cached-provider": "^0.0.1",
29
28
  "opencode-deterministic-provider": "^0.0.1",
29
+ "opencode-cached-provider": "^0.0.1",
30
30
  "db": "^0.0.0"
31
31
  },
32
32
  "dependencies": {
@@ -63,8 +63,8 @@
63
63
  "zod": "^4.3.6",
64
64
  "zustand": "^5.0.11",
65
65
  "errore": "^0.14.1",
66
- "libsqlproxy": "^0.1.0",
67
66
  "opencode-injection-guard": "^0.2.1",
67
+ "libsqlproxy": "^0.1.0",
68
68
  "traforo": "^0.7.1"
69
69
  },
70
70
  "optionalDependencies": {
@@ -54,6 +54,23 @@ The `--model` / `-m` flag is **optional** on both `egaki image` and `egaki video
54
54
 
55
55
  Agents should always pass `-m` explicitly to avoid the interactive picker.
56
56
 
57
+ ### Preferred image models
58
+
59
+ When the user does not specify a model, prefer these in order:
60
+
61
+ 1. **`gpt-image-1`** (or `gpt-image-2` / `chatgpt-image-latest` if available)
62
+ 2. **`grok-imagine-image`** (xAI Grok)
63
+ 3. **`nano-banana-pro-preview`**
64
+
65
+ Always try the first available model. Fall back to the next if auth is missing
66
+ for the preferred one.
67
+
68
+ ### Preferred video models
69
+
70
+ When the user does not specify a video model, prefer:
71
+
72
+ 1. **`grok-imagine-video-1.5`** (xAI Grok)
73
+
57
74
  ## Example commands
58
75
 
59
76
  ```bash
@@ -178,6 +178,23 @@ import { openInBrowser } from 'goke'
178
178
  await openInBrowser('https://example.com/dashboard')
179
179
  ```
180
180
 
181
+ ## Command Descriptions and Examples
182
+
183
+ **Use backtick formatting in descriptions** for flags and command references (e.g. `` `--status` ``, `` `mycli deploy` ``). Plain text flag names won't render as code in generated docs.
184
+
185
+ **Use `.example()` for usage examples, not the description string.** `generateDocs()` auto-wraps `.example()` strings in fenced `` ```sh `` code blocks. Examples in the description render as plain text without syntax highlighting.
186
+
187
+ ```ts
188
+ cli.command(
189
+ 'query <sql>',
190
+ dedent`
191
+ Run a SQL query. Add \`--json\` for the raw JSON envelope.
192
+ `,
193
+ )
194
+ .example('mycli query "SELECT * FROM users" -p my-app')
195
+ .example('mycli query "SELECT * FROM users FORMAT CSV" > out.csv')
196
+ ```
197
+
181
198
  ## Command Naming Conventions
182
199
 
183
200
  **ALWAYS read existing commands before adding a new one.** Scan the CLI for option names, verbs, and noun patterns already in use. New commands must stay consistent with what exists.
@@ -43,6 +43,7 @@ When the user asks about a specific workflow, fetch the matching page directly:
43
43
  - **Node deploy**: https://holocron.so/docs/deploy/node.md
44
44
  - **Holocron deploy**: https://holocron.so/docs/deploy/holocron.md
45
45
  - **AI assistant docs**: https://holocron.so/docs/ai/assistant.md
46
+ - **Bleed**: https://holocron.so/docs/customize/bleed.md
46
47
 
47
48
  ```bash
48
49
  curl -fsSL https://holocron.so/docs/quickstart.md
@@ -326,6 +327,36 @@ sidebarTitle: Auth Providers
326
327
  below the title and in search engine snippets. Write a single sentence that
327
328
  summarizes the page content. Longer descriptions get truncated with an ellipsis.
328
329
 
330
+ ## Page titles (MUST follow)
331
+
332
+ Holocron renders the browser `<title>` as `{page title} — {site name}`. Two
333
+ frontmatter fields control what appears where:
334
+
335
+ - **`title`** — browser tab, Google results, OG image, page heading. Write for SEO.
336
+ - **`sidebarTitle`** — sidebar navigation only. Write for scannability (short label).
337
+
338
+ **Always set both.** `title` is the full descriptive phrase; `sidebarTitle` is
339
+ the short sidebar label. Without `sidebarTitle`, the full title shows in the
340
+ sidebar and often wraps or looks verbose.
341
+
342
+ ```mdx
343
+ ---
344
+ title: Open-source browser automation for AI agents
345
+ sidebarTitle: Home
346
+ description: Automate any browser with a simple TypeScript API.
347
+ ---
348
+ ```
349
+
350
+ Browser tab: `Open-source browser automation for AI agents — Playwriter`
351
+ Sidebar: `Home`
352
+
353
+ **Rules:**
354
+
355
+ - **Never set `title` identical to the site name.** `Playwriter — Playwriter` is redundant.
356
+ - **Every `title` MUST be descriptive, not a generic label.** `Introduction`, `Getting Started`, `Overview` say nothing in a browser tab. Write what the page covers.
357
+ - **Every `title` MUST be unique across the site.**
358
+ - **`index.mdx` MUST use `sidebarTitle: Home`** (or `Overview`) and a descriptive `title`.
359
+
329
360
  ## Custom entry (mounting docs inside a Spiceflow app)
330
361
 
331
362
  When the site mounts Holocron inside an existing **Spiceflow** app (the `entry`
@@ -416,6 +447,12 @@ below the main content to accommodate the aside height. Keep aside callouts
416
447
  short, and only place them in sections that have at least a few paragraphs of
417
448
  body text.
418
449
 
450
+ ## Moving or renaming a page
451
+
452
+ When moving or renaming a page, **always add a redirect** from the old slug to
453
+ the new one so existing links and bookmarks don't break. Update internal links
454
+ in other pages and the slug in `docs.json` navigation to match.
455
+
419
456
  ## New pages and navigation
420
457
 
421
458
  After creating a new `.mdx` or `.md` page, add its slug to `docs.jsonc`
@@ -569,7 +606,7 @@ Use ASCII diagrams frequently in MDX pages. Always use the **`diagram`** languag
569
606
  - Never exceed 94 characters per line.
570
607
  - Prefer a **varied, organic layout**. Mix plain text labels, boxes for major components, and directional arrows.
571
608
  - All connections must use **directional arrows**. Never use plain lines without an arrowhead.
572
- - Verify alignment by counting characters precisely.
609
+ - Verify alignment by running `npx -y @holocron.so/cli diagrams fix <file>` (see dedicated section below).
573
610
 
574
611
  ````mdx
575
612
  ```diagram
@@ -590,6 +627,23 @@ docs.jsonc ───►│ Vite Plugin │──────► Build Output
590
627
  ```
591
628
  ````
592
629
 
630
+ ## Always run `diagrams fix` after editing diagrams
631
+
632
+ LLMs cannot count characters reliably. Every time you create or edit a diagram in an MDX page, you **must** run the alignment fixer before committing. This is not optional.
633
+
634
+ ```bash
635
+ npx -y @holocron.so/cli diagrams fix path/to/file.mdx
636
+ ```
637
+
638
+ The command:
639
+ - Fixes box alignment (padding, border widths, junctions) in-place
640
+ - Reports how many lines were changed
641
+ - Exits with code 1 if any lines exceed max width (94 cols by default)
642
+
643
+ If the output says "No changes", the diagram was already correct. If it reports width violations, shorten the offending lines manually and re-run.
644
+
645
+ **Do not skip this step.** Even if the diagram looks correct in your editor, run the command. Off-by-one padding errors are invisible to LLMs but obvious to humans in monospace fonts.
646
+
593
647
  ## OpenAPI summaries
594
648
 
595
649
  OpenAPI operation `summary` fields become sidebar titles. The sidebar is only
@@ -601,6 +655,15 @@ characters. Use the same concise style as page `sidebarTitle` fields.
601
655
  - Never repeat the tag/group name in the summary. If the tag is `Deploy`, the summary should not start with "Deploy".
602
656
  - If the summary reads well as a sidebar label at 230px, it is short enough.
603
657
 
658
+ ## Bleed — extending content past the prose column
659
+
660
+ Always wrap **YouTube embeds, videos, and large images** in `<div className='bleed'>`
661
+ so they extend into both page margins instead of sitting at narrow prose width.
662
+ `<Frame>` also accepts `className='bleed'`. Containers like Callout, Card, and
663
+ Accordion apply `no-bleed` automatically to keep descendants inside their frame.
664
+
665
+ See https://holocron.so/docs/customize/bleed.md for the full reference.
666
+
604
667
  ## Agent rules
605
668
 
606
669
  - **Always place MDX pages inside the Holocron `pagesDir`.** Before creating or
@@ -614,5 +677,7 @@ characters. Use the same concise style as page `sidebarTitle` fields.
614
677
  - All ASCII diagrams in MDX pages must use the `diagram` language hint on the
615
678
  code fence (` ```diagram `). This renders them with proper styling on the
616
679
  website instead of plain monospace.
680
+ - After creating or editing any diagram, run `npx -y @holocron.so/cli diagrams fix <file>`
681
+ to auto-fix alignment. LLMs cannot count characters reliably.
617
682
  - Never pipe curl or `--help` output through `head`, `tail`, or any truncation
618
683
  command. Always read the full output.
@@ -25,6 +25,19 @@ sigillo --help
25
25
 
26
26
  **NEVER truncate this either.**
27
27
 
28
+ If a flag or command documented here is missing from `--help`, the installed binary is likely outdated. Update before proceeding:
29
+
30
+ ```bash
31
+ npm i -g sigillo@latest
32
+ ```
33
+
34
+ If sigillo is symlinked to a local dev build (check with `which sigillo`), rebuild from source instead:
35
+
36
+ ```bash
37
+ cd /path/to/sigillo/cli && zig build -Dtarget=aarch64-macos
38
+ cp zig-out/bin/sigillo dist/darwin-arm64/sigillo
39
+ ```
40
+
28
41
  ## New project setup workflow
29
42
 
30
43
  This mirrors the Doppler workflow: check auth → link project → list secrets → run.
@@ -88,6 +101,16 @@ To read a specific value:
88
101
  sigillo secrets get DATABASE_URL
89
102
  ```
90
103
 
104
+ `secrets get` returns YAML by default. Pass `--raw` to output only the raw value (no YAML wrapping). When stdout is piped, `--raw` is implied automatically:
105
+
106
+ ```bash
107
+ # copy a secret between environments (piped stdout auto-enables raw mode)
108
+ sigillo secrets get DATABASE_URL -c dev | sigillo secrets set DATABASE_URL -c preview
109
+
110
+ # force raw output in a terminal (useful for scripting)
111
+ sigillo secrets get DATABASE_URL --raw
112
+ ```
113
+
91
114
  **5. Run your app with secrets injected:**
92
115
 
93
116
  ```bash
@@ -113,11 +136,11 @@ Never read a secret value into the agent context window or pass it as plain text
113
136
  **Copying a secret from one env to another:**
114
137
 
115
138
  ```bash
116
- # value flows through stdin, never seen by the agent
139
+ # piped stdout auto-enables raw mode, value never seen by the agent
117
140
  sigillo secrets get DATABASE_URL -c dev | sigillo secrets set DATABASE_URL -c preview
118
141
  ```
119
142
 
120
- The same pattern works for any secret copy between environments, or when seeding a new environment from an existing one.
143
+ The same pattern works for any secret copy, between environments, or when seeding a new environment from an existing one.
121
144
 
122
145
  ### Never read `.env` files or `~/.sigillo/*`
123
146
 
@@ -145,14 +168,22 @@ If a `package.json` script requires secrets, embed `sigillo run` directly in the
145
168
  {
146
169
  "scripts": {
147
170
  "dev": "sigillo run -c dev -- vite dev",
148
- "deployment": "sigillo run -c preview -- pnpm db:migrate:preview && CLOUDFLARE_ENV=preview sigillo run -c preview --command 'vite build && wrangler deploy --env preview'",
149
- "deployment:prod": "sigillo run -c prod -- pnpm db:migrate:prod && sigillo run -c prod --command 'vite build && wrangler deploy'"
171
+ "deploy": "pnpm db:migrate:preview && CLOUDFLARE_ENV=preview sigillo run -c preview --command 'tsc && vite build && wrangler deploy --env preview'",
172
+ "deploy:prod": "pnpm db:migrate:prod && sigillo run -c prod --command 'tsc && vite build && wrangler deploy'"
150
173
  }
151
174
  }
152
175
  ```
153
176
 
177
+ These scripts work because pnpm adds `node_modules/.bin` to PATH before executing the script string, and sigillo inherits that PATH. Commands like `tsc`, `vite`, and `wrangler` resolve correctly inside `--command` as long as the entry point is `pnpm run deploy`.
178
+
154
179
  Use `-c dev` for local development, `-c preview` for staging or preview deployments, and `-c prod` or the repo's production slug for production.
155
180
 
181
+ ### Always run `sigillo run` via pnpm scripts, not directly
182
+
183
+ `sigillo run --command` spawns `$SHELL -c '...'` and passes the inherited `PATH`. But pnpm only adds `node_modules/.bin` to `PATH` when it's running a `package.json` script. If you call `sigillo run --command 'tsc && vite build'` directly from the terminal, `tsc` and `vite` won't be found because `node_modules/.bin` is not in PATH.
184
+
185
+ Always invoke deploy/build commands through `pnpm run <script>` so pnpm augments PATH before sigillo inherits it. If you must run sigillo directly, use `pnpm exec` to resolve binaries: `sigillo run --command 'pnpm exec tsc && pnpm exec vite build'`.
186
+
156
187
  ### Use `--command` for shell expansion
157
188
 
158
189
  Use direct argv mode when no shell syntax is needed:
@@ -47,3 +47,16 @@ The README and `zele --help` output are the source of truth for commands, option
47
47
  ```
48
48
  5. **Google-only features** (labels, Gmail filters, `zele cal *`, full profile) fail on IMAP accounts with a clear error. Check `zele whoami` output for account type before using them.
49
49
  6. **Headless Google login** requires a tmux wrapper because `zele login` is interactive. See the README "Remote / headless login" section for the exact pattern.
50
+ 7. **Waiting for emails** with `zele mail watch`. It polls for new emails matching a filter and exits as soon as one arrives. Use this to wait for replies, verification codes, or any expected email:
51
+ ```bash
52
+ # wait for a reply from alice (no timeout, blocks until match)
53
+ zele mail watch --filter "is:unread from:alice@example.com"
54
+
55
+ # wait for a verification code with a 5-minute timeout
56
+ zele mail watch --filter "is:unread subject:verification" --timeout 300
57
+
58
+ # send an email then wait for the reply
59
+ zele mail send --to bob@example.com --subject "Question" --body "Hey, can you check this?"
60
+ zele mail watch --filter "is:unread from:bob@example.com subject:Re:Question" --timeout 600
61
+ ```
62
+ If the matched email wasn't the expected one, call `zele mail watch` again with a more specific filter. Exit code 0 means a match was found, exit code 1 means timeout.
@@ -5,7 +5,7 @@ import { note } from '@clack/prompts'
5
5
  import YAML from 'yaml'
6
6
  import * as errore from 'errore'
7
7
  import type { OpencodeClient, Event as OpenCodeEvent } from '@opencode-ai/sdk/v2'
8
- import { Events, ActivityType, type PresenceStatusData, type Guild, Routes } from 'discord.js'
8
+ import { Events, ActivityType, type PresenceStatusData, type Guild, type Client, Routes } from 'discord.js'
9
9
  import path from 'node:path'
10
10
  import fs from 'node:fs'
11
11
  import { fileURLToPath } from 'node:url'
@@ -98,74 +98,7 @@ cli
98
98
 
99
99
  cliLogger.log('Finding guild...')
100
100
 
101
- // Find guild
102
- let guild: Guild
103
- if (options.guild) {
104
- const guildId = String(options.guild)
105
- const foundGuild = client.guilds.cache.get(guildId)
106
- if (!foundGuild) {
107
- cliLogger.log('Guild not found')
108
- cliLogger.error(`Guild not found: ${guildId}`)
109
- void client.destroy()
110
- process.exit(EXIT_NO_RESTART)
111
- }
112
- guild = foundGuild
113
- } else {
114
- const existingChannelId = await (await getDb()).query.channel_directories.findFirst({
115
- where: { channel_type: 'text' },
116
- orderBy: { created_at: 'desc' },
117
- columns: { channel_id: true },
118
- }).then((row) => row?.channel_id)
119
-
120
- if (existingChannelId) {
121
- try {
122
- const ch = await client.channels.fetch(existingChannelId)
123
- if (ch && !ch.isDMBased()) {
124
- guild = ch.guild
125
- } else {
126
- throw new Error('Channel has no guild')
127
- }
128
- } catch (error) {
129
- cliLogger.debug(
130
- 'Failed to fetch existing channel while selecting guild:',
131
- error instanceof Error ? error.stack : String(error),
132
- )
133
- let firstGuild = client.guilds.cache.first()
134
- if (!firstGuild) {
135
- // Cache might be empty, try fetching guilds from API
136
- const fetched = await client.guilds.fetch()
137
- const firstOAuth2Guild = fetched.first()
138
- if (firstOAuth2Guild) {
139
- firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
140
- }
141
- }
142
- if (!firstGuild) {
143
- cliLogger.log('No guild found')
144
- cliLogger.error('No guild found. Add the bot to a server first.')
145
- void client.destroy()
146
- process.exit(EXIT_NO_RESTART)
147
- }
148
- guild = firstGuild
149
- }
150
- } else {
151
- let firstGuild = client.guilds.cache.first()
152
- if (!firstGuild) {
153
- // Cache might be empty, try fetching guilds from API
154
- const fetched = await client.guilds.fetch()
155
- const firstOAuth2Guild = fetched.first()
156
- if (firstOAuth2Guild) {
157
- firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
158
- }
159
- }
160
- if (!firstGuild) {
161
- cliLogger.log('No guild found')
162
- cliLogger.error('No guild found. Add the bot to a server first.')
163
- void client.destroy()
164
- process.exit(EXIT_NO_RESTART)
165
- }
166
- guild = firstGuild
167
- }
168
- }
101
+ const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild })
169
102
 
170
103
  // Check if channel already exists in this guild
171
104
  cliLogger.log('Checking for existing channel...')
@@ -466,24 +399,7 @@ cli
466
399
  client.login(botToken).catch(reject)
467
400
  })
468
401
 
469
- let guild: Guild
470
- if (options.guild) {
471
- const found = client.guilds.cache.get(options.guild)
472
- if (!found) {
473
- cliLogger.error(`Guild not found: ${options.guild}`)
474
- void client.destroy()
475
- process.exit(EXIT_NO_RESTART)
476
- }
477
- guild = found
478
- } else {
479
- const first = client.guilds.cache.first()
480
- if (!first) {
481
- cliLogger.error('No guild found. Add the bot to a server first.')
482
- void client.destroy()
483
- process.exit(EXIT_NO_RESTART)
484
- }
485
- guild = first
486
- }
402
+ const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild })
487
403
 
488
404
  const { textChannelId, channelName } = await createProjectChannels({
489
405
  guild,
@@ -505,4 +421,100 @@ cli
505
421
  })
506
422
 
507
423
 
424
+ // Resolve the guild for project add/create commands. In gateway mode the
425
+ // guild cache only contains authorized guilds, so picking from cache is safe.
426
+ // The old approach fetched an existing channel to infer the guild, but that
427
+ // breaks when the channel belongs to a different guild (e.g. old self-hosted
428
+ // bot channels) and the gateway proxy rejects the REST call. This led to a
429
+ // non-deterministic fallback that picked the wrong guild.
430
+ async function resolveGuildForProjectCommand({ client, guildIdOverride }: { client: Client; guildIdOverride?: string }): Promise<Guild> {
431
+ if (guildIdOverride) {
432
+ const found = client.guilds.cache.get(guildIdOverride)
433
+ if (!found) {
434
+ cliLogger.error(`Guild not found: ${guildIdOverride}`)
435
+ void client.destroy()
436
+ process.exit(EXIT_NO_RESTART)
437
+ }
438
+ return found
439
+ }
440
+
441
+ // Try existing channel lookup to find the guild the user already has channels in.
442
+ // This handles multi-guild setups where we want to add to the same guild.
443
+ const db = await getDb()
444
+ const existingChannels = await db.query.channel_directories.findMany({
445
+ where: { channel_type: 'text' },
446
+ orderBy: { created_at: 'desc' },
447
+ columns: { channel_id: true },
448
+ limit: 20,
449
+ })
450
+
451
+ // Log available guilds for debugging guild selection issues
452
+ const cachedGuilds = Array.from(client.guilds.cache.values())
453
+ cliLogger.debug(`Guilds in cache (${cachedGuilds.length}): ${cachedGuilds.map((g) => `${g.name} (${g.id})`).join(', ')}`)
454
+
455
+ // When multiple guilds are available, find which guild has the most
456
+ // existing channels. The user's main guild will have far more channels
457
+ // than a test/demo guild.
458
+ const guildHits = new Map<string, { guild: Guild; count: number }>()
459
+ for (const row of existingChannels) {
460
+ try {
461
+ const ch = await client.channels.fetch(row.channel_id)
462
+ if (ch && !ch.isDMBased()) {
463
+ const entry = guildHits.get(ch.guild.id)
464
+ if (entry) {
465
+ entry.count++
466
+ } else {
467
+ guildHits.set(ch.guild.id, { guild: ch.guild, count: 1 })
468
+ }
469
+ }
470
+ } catch {
471
+ // Channel might be in a different guild (gateway proxy rejects) or deleted, skip
472
+ }
473
+ }
474
+
475
+ if (guildHits.size > 0) {
476
+ // Pick the guild with the most channels
477
+ const best = Array.from(guildHits.values()).sort((a, b) => b.count - a.count)[0]!
478
+ cliLogger.debug(
479
+ `Guild channel counts: ${Array.from(guildHits.values()).map((e) => `${e.guild.name} (${e.guild.id}): ${e.count}`).join(', ')}`,
480
+ )
481
+ cliLogger.debug(`Selected guild: ${best.guild.name} (${best.guild.id}) with ${best.count} channels`)
482
+ return best.guild
483
+ }
484
+
485
+ cliLogger.debug('Could not resolve guild from existing channels, falling back to cache')
486
+
487
+ // If only one guild in cache, use it directly (common case).
488
+ // If multiple guilds, error out and ask the user to specify --guild
489
+ // since we can't determine which one to use.
490
+ if (cachedGuilds.length === 1) {
491
+ return cachedGuilds[0]!
492
+ }
493
+ if (cachedGuilds.length > 1) {
494
+ cliLogger.error(
495
+ `Multiple guilds found. Use --guild to specify which one:\n${cachedGuilds.map((g) => ` ${g.id} ${g.name}`).join('\n')}`,
496
+ )
497
+ void client.destroy()
498
+ process.exit(EXIT_NO_RESTART)
499
+ }
500
+
501
+ // Cache empty, try fetching
502
+ const fetched = await client.guilds.fetch()
503
+ if (fetched.size === 1) {
504
+ const firstOAuth2Guild = fetched.first()!
505
+ return await client.guilds.fetch(firstOAuth2Guild.id)
506
+ }
507
+ if (fetched.size > 1) {
508
+ cliLogger.error(
509
+ `Multiple guilds found. Use --guild to specify which one:\n${Array.from(fetched.values()).map((g) => ` ${g.id} ${g.name}`).join('\n')}`,
510
+ )
511
+ void client.destroy()
512
+ process.exit(EXIT_NO_RESTART)
513
+ }
514
+
515
+ cliLogger.error('No guild found. Add the bot to a server first.')
516
+ void client.destroy()
517
+ process.exit(EXIT_NO_RESTART)
518
+ }
519
+
508
520
  export default cli