kimaki 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/anthropic-auth-plugin.js +78 -33
  2. package/dist/anthropic-auth-state.js +62 -27
  3. package/dist/anthropic-auth-state.test.js +55 -2
  4. package/dist/channel-management.js +29 -6
  5. package/dist/cli-commands/project.js +87 -15
  6. package/dist/cli-commands/send.js +43 -3
  7. package/dist/cli-runner.js +57 -1
  8. package/dist/commands/btw.js +4 -3
  9. package/dist/commands/login.js +1 -1
  10. package/dist/commands/new-worktree.js +23 -5
  11. package/dist/commands/queue.js +12 -2
  12. package/dist/commands/session.js +66 -13
  13. package/dist/commands/tasks.js +10 -3
  14. package/dist/commands/user-command.js +3 -2
  15. package/dist/database.js +7 -8
  16. package/dist/db.js +1 -0
  17. package/dist/discord-bot.js +47 -8
  18. package/dist/hrana-server.js +19 -0
  19. package/dist/message-preprocessing.js +9 -2
  20. package/dist/oauth-rotation-shared.js +21 -0
  21. package/dist/opencode.js +73 -4
  22. package/dist/schema.js +3 -0
  23. package/dist/session-handler/global-event-listener.js +38 -6
  24. package/dist/session-handler/thread-runtime-state.js +2 -0
  25. package/dist/session-handler/thread-session-runtime.js +19 -3
  26. package/dist/system-message.js +52 -26
  27. package/dist/system-message.test.js +49 -26
  28. package/dist/task-runner.js +8 -4
  29. package/dist/worktree-lifecycle.e2e.test.js +84 -1
  30. package/dist/worktrees.js +63 -0
  31. package/package.json +3 -3
  32. package/skills/holocron/SKILL.md +34 -8
  33. package/src/anthropic-auth-plugin.ts +82 -35
  34. package/src/anthropic-auth-state.test.ts +73 -1
  35. package/src/anthropic-auth-state.ts +85 -25
  36. package/src/channel-management.ts +35 -6
  37. package/src/cli-commands/project.ts +105 -17
  38. package/src/cli-commands/send.ts +56 -5
  39. package/src/cli-runner.ts +81 -0
  40. package/src/commands/btw.ts +6 -2
  41. package/src/commands/login.ts +1 -1
  42. package/src/commands/new-worktree.ts +28 -4
  43. package/src/commands/queue.ts +13 -2
  44. package/src/commands/session.ts +79 -17
  45. package/src/commands/tasks.ts +11 -3
  46. package/src/commands/user-command.ts +3 -2
  47. package/src/database.ts +8 -9
  48. package/src/db.ts +1 -0
  49. package/src/discord-bot.ts +54 -7
  50. package/src/hrana-server.ts +19 -0
  51. package/src/message-preprocessing.ts +11 -2
  52. package/src/oauth-rotation-shared.ts +22 -0
  53. package/src/opencode.ts +90 -6
  54. package/src/schema.sql +1 -0
  55. package/src/schema.ts +3 -0
  56. package/src/session-handler/global-event-listener.ts +38 -6
  57. package/src/session-handler/thread-runtime-state.ts +4 -1
  58. package/src/session-handler/thread-session-runtime.ts +25 -3
  59. package/src/system-message.test.ts +49 -26
  60. package/src/system-message.ts +52 -26
  61. package/src/task-runner.ts +8 -4
  62. package/src/worktree-lifecycle.e2e.test.ts +104 -1
  63. package/src/worktrees.ts +74 -0
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { appendToastSessionMarker } from "./plugin-logger.js";
26
26
  import { createPluginClient } from "./plugin-opencode-client.js";
27
- import { loadAccountStore, rememberAnthropicOAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
27
+ import { isPermanentOAuthRefreshFailure, loadAccountStore, rememberAnthropicOAuth, removeAccountByAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
28
28
  import { extractAnthropicAccountIdentity, } from "./anthropic-account-identity.js";
29
29
  // PKCE (Proof Key for Code Exchange) using Web Crypto API.
30
30
  // Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
@@ -723,45 +723,86 @@ function mergeBetas(existing, required) {
723
723
  function isOAuthStored(auth) {
724
724
  return auth.type === "oauth";
725
725
  }
726
- async function getFreshOAuth(getAuth, client) {
726
+ /**
727
+ * Refresh Anthropic OAuth. On permanent refresh failure (invalid_grant),
728
+ * remove that account from the pool and retry with the next one.
729
+ * removeAccountByAuth runs outside withAuthStateLock to avoid nested locks.
730
+ */
731
+ async function getFreshOAuth(getAuth, client, options) {
727
732
  const auth = await getAuth();
728
733
  if (!isOAuthStored(auth))
729
734
  return undefined;
730
735
  if (auth.access && auth.expires > Date.now())
731
736
  return auth;
732
737
  const pending = pendingRefresh.get(auth.refresh);
733
- if (pending) {
738
+ if (pending)
734
739
  return pending;
735
- }
736
- const refreshPromise = withAuthStateLock(async () => {
737
- const latest = await getAuth();
738
- if (!isOAuthStored(latest)) {
739
- throw new Error("Anthropic OAuth credentials disappeared during refresh");
740
- }
741
- if (latest.access && latest.expires > Date.now())
742
- return latest;
743
- const refreshed = await refreshAnthropicToken(latest.refresh);
744
- await setAnthropicAuth(refreshed, client);
745
- const store = await loadAccountStore();
746
- if (store.accounts.length > 0) {
747
- const identity = (() => {
748
- const currentIndex = store.accounts.findIndex((account) => {
749
- return (account.refresh === latest.refresh ||
750
- account.access === latest.access);
740
+ const refreshPromise = (async () => {
741
+ const attempted = new Set();
742
+ while (true) {
743
+ let failedAuth;
744
+ try {
745
+ return await withAuthStateLock(async () => {
746
+ const latest = await getAuth();
747
+ if (!isOAuthStored(latest)) {
748
+ throw new Error("Anthropic OAuth credentials disappeared during refresh");
749
+ }
750
+ if (latest.access && latest.expires > Date.now())
751
+ return latest;
752
+ if (attempted.has(latest.refresh)) {
753
+ throw new Error("Anthropic OAuth refresh failed for all remaining accounts");
754
+ }
755
+ attempted.add(latest.refresh);
756
+ try {
757
+ const refreshed = await refreshAnthropicToken(latest.refresh);
758
+ await setAnthropicAuth(refreshed, client);
759
+ const store = await loadAccountStore();
760
+ if (store.accounts.length > 0) {
761
+ const current = store.accounts.find((account) => account.refresh === latest.refresh ||
762
+ account.access === latest.access);
763
+ const identity = current
764
+ ? {
765
+ ...(current.email ? { email: current.email } : {}),
766
+ ...(current.accountId
767
+ ? { accountId: current.accountId }
768
+ : {}),
769
+ }
770
+ : undefined;
771
+ upsertAccount(store, { ...refreshed, ...identity });
772
+ await saveAccountStore(store);
773
+ }
774
+ return refreshed;
775
+ }
776
+ catch (error) {
777
+ if (!isPermanentOAuthRefreshFailure(error))
778
+ throw error;
779
+ failedAuth = latest;
780
+ throw error;
781
+ }
751
782
  });
752
- const current = currentIndex >= 0 ? store.accounts[currentIndex] : undefined;
753
- if (!current)
754
- return undefined;
755
- return {
756
- ...(current.email ? { email: current.email } : {}),
757
- ...(current.accountId ? { accountId: current.accountId } : {}),
758
- };
759
- })();
760
- upsertAccount(store, { ...refreshed, ...identity });
761
- await saveAccountStore(store);
783
+ }
784
+ catch (error) {
785
+ if (!failedAuth || !isPermanentOAuthRefreshFailure(error))
786
+ throw error;
787
+ const removed = await removeAccountByAuth(failedAuth, client);
788
+ if (!removed)
789
+ throw error;
790
+ client.tui
791
+ .showToast({
792
+ message: appendToastSessionMarker({
793
+ message: removed.active
794
+ ? `Removed expired Anthropic account ${removed.removedLabel}, switched to ${removed.activeLabel ?? "next"}`
795
+ : `Removed expired Anthropic account ${removed.removedLabel} (no accounts left — re-login required)`,
796
+ sessionId: options?.sessionId,
797
+ }),
798
+ variant: removed.active ? "info" : "error",
799
+ })
800
+ .catch(() => { });
801
+ if (!removed.active)
802
+ throw error;
803
+ }
762
804
  }
763
- return refreshed;
764
- });
805
+ })();
765
806
  pendingRefresh.set(auth.refresh, refreshPromise);
766
807
  return refreshPromise.finally(() => {
767
808
  pendingRefresh.delete(auth.refresh);
@@ -846,7 +887,9 @@ const AnthropicAuthPlugin = async ({ serverUrl, directory }) => {
846
887
  headers: requestHeaders,
847
888
  });
848
889
  };
849
- const freshAuth = await getFreshOAuth(getAuth, client);
890
+ const freshAuth = await getFreshOAuth(getAuth, client, {
891
+ sessionId,
892
+ });
850
893
  if (!freshAuth)
851
894
  return fetch(input, init);
852
895
  let response = await runRequest(freshAuth);
@@ -868,7 +911,9 @@ const AnthropicAuthPlugin = async ({ serverUrl, directory }) => {
868
911
  variant: "info",
869
912
  })
870
913
  .catch(() => { });
871
- const retryAuth = await getFreshOAuth(getAuth, client);
914
+ const retryAuth = await getFreshOAuth(getAuth, client, {
915
+ sessionId,
916
+ });
872
917
  if (retryAuth) {
873
918
  response = await runRequest(retryAuth);
874
919
  }
@@ -7,8 +7,8 @@
7
7
  import { homedir } from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { normalizeAnthropicAccountIdentity, } from './anthropic-account-identity.js';
10
- import { accountLabel, authFilePath, findCurrentAccountIndex, isOAuthStored, normalizeAccountStore, readJson, upsertAccount as sharedUpsertAccount, withAuthStateLock, writeJson, shouldRotateAuth, } from './oauth-rotation-shared.js';
11
- export { accountLabel, authFilePath, withAuthStateLock, shouldRotateAuth };
10
+ import { accountLabel, authFilePath, findCurrentAccountIndex, isOAuthStored, isPermanentOAuthRefreshFailure, normalizeAccountStore, readJson, upsertAccount as sharedUpsertAccount, withAuthStateLock, writeJson, shouldRotateAuth, } from './oauth-rotation-shared.js';
11
+ export { accountLabel, authFilePath, withAuthStateLock, shouldRotateAuth, isPermanentOAuthRefreshFailure, };
12
12
  // --- Store file path ---
13
13
  export function accountsFilePath() {
14
14
  if (process.env.XDG_DATA_HOME) {
@@ -114,37 +114,72 @@ export async function rotateAnthropicAccount(auth, client) {
114
114
  });
115
115
  }
116
116
  // --- Remove account ---
117
+ /** Splice account at index and promote next. Caller must hold withAuthStateLock. */
118
+ async function spliceAccountAndPromote({ store, index, client, }) {
119
+ store.accounts.splice(index, 1);
120
+ if (store.accounts.length === 0) {
121
+ store.activeIndex = 0;
122
+ await saveAccountStore(store);
123
+ await writeAnthropicAuthFile(undefined);
124
+ return { store, active: undefined, activeLabel: undefined };
125
+ }
126
+ if (store.activeIndex > index) {
127
+ store.activeIndex -= 1;
128
+ }
129
+ else if (store.activeIndex >= store.accounts.length) {
130
+ store.activeIndex = 0;
131
+ }
132
+ const active = store.accounts[store.activeIndex];
133
+ if (!active)
134
+ throw new Error('Active Anthropic account disappeared during removal');
135
+ active.lastUsed = Date.now();
136
+ await saveAccountStore(store);
137
+ const nextAuth = {
138
+ type: 'oauth',
139
+ refresh: active.refresh,
140
+ access: active.access,
141
+ expires: active.expires,
142
+ };
143
+ if (client) {
144
+ await setAnthropicAuth(nextAuth, client);
145
+ }
146
+ else {
147
+ await writeAnthropicAuthFile(nextAuth);
148
+ }
149
+ return {
150
+ store,
151
+ active: nextAuth,
152
+ activeLabel: accountLabel(active, store.activeIndex),
153
+ };
154
+ }
117
155
  export async function removeAccount(index) {
118
156
  return withAuthStateLock(async () => {
119
157
  const store = await loadAccountStore();
120
158
  if (!Number.isInteger(index) || index < 0 || index >= store.accounts.length) {
121
159
  throw new Error(`Account ${index + 1} does not exist`);
122
160
  }
123
- store.accounts.splice(index, 1);
124
- if (store.accounts.length === 0) {
125
- store.activeIndex = 0;
126
- await saveAccountStore(store);
127
- await writeAnthropicAuthFile(undefined);
128
- return { store, active: undefined };
129
- }
130
- if (store.activeIndex > index) {
131
- store.activeIndex -= 1;
132
- }
133
- else if (store.activeIndex >= store.accounts.length) {
134
- store.activeIndex = 0;
135
- }
136
- const active = store.accounts[store.activeIndex];
137
- if (!active)
138
- throw new Error('Active Anthropic account disappeared during removal');
139
- active.lastUsed = Date.now();
140
- await saveAccountStore(store);
141
- const nextAuth = {
142
- type: 'oauth',
143
- refresh: active.refresh,
144
- access: active.access,
145
- expires: active.expires,
161
+ return spliceAccountAndPromote({ store, index });
162
+ });
163
+ }
164
+ /**
165
+ * Remove the pool entry matching these OAuth credentials and promote the next
166
+ * account. Used when refresh fails permanently (invalid_grant).
167
+ */
168
+ export async function removeAccountByAuth(auth, client) {
169
+ return withAuthStateLock(async () => {
170
+ const store = await loadAccountStore();
171
+ const index = store.accounts.findIndex((account) => account.refresh === auth.refresh || account.access === auth.access);
172
+ if (index < 0)
173
+ return undefined;
174
+ const removed = store.accounts[index];
175
+ if (!removed)
176
+ return undefined;
177
+ const removedLabel = accountLabel(removed, index);
178
+ const result = await spliceAccountAndPromote({ store, index, client });
179
+ return {
180
+ removedLabel,
181
+ activeLabel: result.activeLabel,
182
+ active: result.active,
146
183
  };
147
- await writeAnthropicAuthFile(nextAuth);
148
- return { store, active: nextAuth };
149
184
  });
150
185
  }
@@ -1,9 +1,10 @@
1
- // Tests Anthropic OAuth account persistence, deduplication, and rotation.
1
+ // Tests Anthropic OAuth account persistence, deduplication, rotation, and
2
+ // permanent refresh-failure removal.
2
3
  import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
- import { accountLabel, authFilePath, loadAccountStore, rememberAnthropicOAuth, removeAccount, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
7
+ import { accountLabel, authFilePath, isPermanentOAuthRefreshFailure, loadAccountStore, rememberAnthropicOAuth, removeAccount, removeAccountByAuth, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
7
8
  const firstAccount = {
8
9
  type: 'oauth',
9
10
  refresh: 'refresh-first',
@@ -141,6 +142,51 @@ describe('removeAccount', () => {
141
142
  expect(authJson.anthropic).toBeUndefined();
142
143
  });
143
144
  });
145
+ describe('removeAccountByAuth', () => {
146
+ test('removes matched account, promotes next, and syncs auth.set', async () => {
147
+ await saveAccountStore({
148
+ version: 1,
149
+ activeIndex: 0,
150
+ accounts: [
151
+ { ...firstAccount, email: 'dead@example.com', addedAt: 1, lastUsed: 1 },
152
+ { ...secondAccount, email: 'live@example.com', addedAt: 2, lastUsed: 2 },
153
+ ],
154
+ });
155
+ const authSetCalls = [];
156
+ const client = {
157
+ auth: {
158
+ set: async (input) => {
159
+ authSetCalls.push(input);
160
+ },
161
+ },
162
+ };
163
+ const removed = await removeAccountByAuth(firstAccount, client);
164
+ const store = await loadAccountStore();
165
+ expect(removed).toMatchObject({
166
+ removedLabel: '#1 (dead@example.com)',
167
+ activeLabel: '#1 (live@example.com)',
168
+ active: { refresh: 'refresh-second' },
169
+ });
170
+ expect(store.accounts).toHaveLength(1);
171
+ expect(store.accounts[0]?.refresh).toBe('refresh-second');
172
+ expect(authSetCalls).toHaveLength(1);
173
+ });
174
+ test('returns undefined when auth is not in the pool', async () => {
175
+ await saveAccountStore({
176
+ version: 1,
177
+ activeIndex: 0,
178
+ accounts: [{ ...firstAccount, addedAt: 1, lastUsed: 1 }],
179
+ });
180
+ const removed = await removeAccountByAuth({
181
+ type: 'oauth',
182
+ refresh: 'unknown-refresh',
183
+ access: 'unknown-access',
184
+ expires: 1,
185
+ }, { auth: { set: async () => { } } });
186
+ expect(removed).toBeUndefined();
187
+ expect((await loadAccountStore()).accounts).toHaveLength(1);
188
+ });
189
+ });
144
190
  describe('shouldRotateAuth', () => {
145
191
  test('only rotates on rate limit or auth failures', () => {
146
192
  expect(shouldRotateAuth(429, '')).toBe(true);
@@ -148,3 +194,10 @@ describe('shouldRotateAuth', () => {
148
194
  expect(shouldRotateAuth(400, 'bad request')).toBe(false);
149
195
  });
150
196
  });
197
+ describe('isPermanentOAuthRefreshFailure', () => {
198
+ test('detects invalid_grant and expired refresh tokens', () => {
199
+ expect(isPermanentOAuthRefreshFailure(new Error('HTTP 400 from https://platform.claude.com/v1/oauth/token: {"error": "invalid_grant", "error_description": "Refresh token expired"}'))).toBe(true);
200
+ expect(isPermanentOAuthRefreshFailure(new Error('ECONNRESET'))).toBe(false);
201
+ expect(isPermanentOAuthRefreshFailure(new Error('rate_limit_error'))).toBe(false);
202
+ });
203
+ });
@@ -113,6 +113,10 @@ __pycache__/
113
113
  .venv/
114
114
  *.egg-info/
115
115
  `;
116
+ /** Returns the absolute path to the default kimaki project directory. */
117
+ export function getDefaultKimakiDirectory() {
118
+ return path.join(getProjectsDir(), 'kimaki');
119
+ }
116
120
  const DEFAULT_CHANNEL_TOPIC = 'General channel for misc tasks with Kimaki. Not connected to a specific OpenCode project or repository.';
117
121
  /**
118
122
  * Create (or find) the default "kimaki" channel for general-purpose tasks.
@@ -124,7 +128,7 @@ const DEFAULT_CHANNEL_TOPIC = 'General channel for misc tasks with Kimaki. Not c
124
128
  * as a fallback for channels created before DB mapping existed.
125
129
  */
126
130
  export async function createDefaultKimakiChannel({ guild, botName, appId, isGatewayMode, }) {
127
- const projectDirectory = path.join(getProjectsDir(), 'kimaki');
131
+ const projectDirectory = getDefaultKimakiDirectory();
128
132
  // Ensure the default kimaki project directory exists before any DB mapping
129
133
  // restoration or git setup. Custom data dirs may not have <dataDir>/projects
130
134
  // created yet, and later writes assume the full path is present.
@@ -146,11 +150,29 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
146
150
  directory: projectDirectory,
147
151
  channelType: 'text',
148
152
  });
149
- const mappedChannelInGuild = existingMappings
150
- .map((row) => guild.channels.cache.get(row.channel_id))
151
- .find((ch) => ch?.type === ChannelType.GuildText);
152
- if (mappedChannelInGuild) {
153
- logger.log(`Default kimaki channel already exists: ${mappedChannelInGuild.id}`);
153
+ const mappedRow = existingMappings.find((row) => {
154
+ const ch = guild.channels.cache.get(row.channel_id);
155
+ return ch?.type === ChannelType.GuildText;
156
+ });
157
+ if (mappedRow) {
158
+ // Backfill guild_id for rows created before this column existed,
159
+ // so the tombstone check works if the channel is deleted later.
160
+ if (mappedRow.guild_id !== guild.id) {
161
+ await setChannelDirectory({
162
+ channelId: mappedRow.channel_id,
163
+ directory: projectDirectory,
164
+ channelType: 'text',
165
+ guildId: guild.id,
166
+ });
167
+ }
168
+ logger.log(`Default kimaki channel already exists: ${mappedRow.channel_id}`);
169
+ return null;
170
+ }
171
+ // 1b. If a mapping exists for this guild but the channel is gone from Discord,
172
+ // it was previously created and then deleted. Don't recreate it.
173
+ const staleForThisGuild = existingMappings.find((row) => row.guild_id === guild.id);
174
+ if (staleForThisGuild) {
175
+ logger.log(`Default kimaki channel was previously provisioned for guild ${guild.name} (${guild.id}) as ${staleForThisGuild.channel_id}, but no longer exists. Skipping recreation.`);
154
176
  return null;
155
177
  }
156
178
  // 2. Fallback: detect existing channel by name+category.
@@ -213,6 +235,7 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
213
235
  channelId: textChannel.id,
214
236
  directory: projectDirectory,
215
237
  channelType: 'text',
238
+ guildId: guild.id,
216
239
  });
217
240
  logger.log(`Created default kimaki channel: #${channelName} (${textChannel.id})`);
218
241
  return {
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  import { spawn, execSync } from 'node:child_process';
12
12
  import { createLogger, LogPrefix, initLogFile } from '../logger.js';
13
13
  import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
14
+ import { getDefaultKimakiDirectory } from '../channel-management.js';
14
15
  import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js';
15
16
  import { ShareMarkdown } from '../markdown.js';
16
17
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
@@ -98,6 +99,8 @@ cli
98
99
  cli
99
100
  .command('project list', 'List all registered projects with their Discord channels')
100
101
  .option('--json', 'Output as JSON')
102
+ .option('--all', 'Include remote projects from other machines (scans Kimaki category in Discord)')
103
+ .option('-g, --guild <guildId>', 'Discord guild/server ID to scan (used with --all when no local projects exist)')
101
104
  .option('--prune', 'Remove stale entries whose Discord channel no longer exists')
102
105
  .action(async (options) => {
103
106
  await initDatabase();
@@ -106,13 +109,10 @@ cli
106
109
  where: { channel_type: 'text' },
107
110
  orderBy: { created_at: 'desc' },
108
111
  });
109
- if (channels.length === 0) {
110
- cliLogger.log('No projects registered');
111
- process.exit(0);
112
- }
113
112
  // Fetch Discord channel names and guild IDs via REST API
114
113
  const botRow = await getBotTokenWithMode();
115
114
  const rest = botRow ? createDiscordRest(botRow.token) : null;
115
+ const localChannelIds = new Set(channels.map((ch) => ch.channel_id));
116
116
  const enriched = await Promise.all(channels.map(async (ch) => {
117
117
  let channelName = '';
118
118
  let guildId = '';
@@ -132,12 +132,17 @@ cli
132
132
  deleted = isUnknownChannel;
133
133
  }
134
134
  }
135
- return { ...ch, channelName, guildId, deleted };
135
+ return { ...ch, channelName, guildId, deleted, isLocal: true };
136
136
  }));
137
137
  // Fetch guild names for unique guild IDs (deduplicated to save API calls)
138
138
  const guildNameMap = new Map();
139
+ // Collect guild IDs from local channels + explicit --guild flag
140
+ const guildIdsFromChannels = enriched.map((ch) => ch.guildId).filter(Boolean);
141
+ if (options.guild) {
142
+ guildIdsFromChannels.push(options.guild);
143
+ }
144
+ const uniqueGuildIds = [...new Set(guildIdsFromChannels)];
139
145
  if (rest) {
140
- const uniqueGuildIds = [...new Set(enriched.map((ch) => ch.guildId).filter(Boolean))];
141
146
  await Promise.all(uniqueGuildIds.map(async (guildId) => {
142
147
  try {
143
148
  const data = (await rest.get(Routes.guild(guildId)));
@@ -148,15 +153,68 @@ cli
148
153
  }
149
154
  }));
150
155
  }
156
+ // When --all is passed, scan each guild's channels to find Kimaki category
157
+ // text channels not in our local DB (projects from other machines).
158
+ // Fail explicitly when prerequisites are missing so the user doesn't
159
+ // confuse "scan never ran" with "no remote projects found".
160
+ let remoteEntries = [];
161
+ if (options.all) {
162
+ if (!rest) {
163
+ cliLogger.error('Discord credentials are required to scan remote projects. Run `kimaki` first.');
164
+ process.exit(EXIT_NO_RESTART);
165
+ }
166
+ if (uniqueGuildIds.length === 0) {
167
+ cliLogger.error('Cannot determine which Discord server to scan. Pass `--guild <guildId>` or register a local project first.');
168
+ process.exit(EXIT_NO_RESTART);
169
+ }
170
+ let guildScanFailures = 0;
171
+ for (const guildId of uniqueGuildIds) {
172
+ try {
173
+ const guildChannels = (await rest.get(Routes.guildChannels(guildId)));
174
+ // Find Kimaki category channels (type 4 = GuildCategory)
175
+ const kimakiCategoryIds = new Set(guildChannels
176
+ .filter((ch) => ch.type === 4 && /^kimaki(\s|$)/i.test(ch.name))
177
+ .map((ch) => ch.id));
178
+ // Find text channels (type 0) in Kimaki categories that are not in our local DB
179
+ for (const ch of guildChannels) {
180
+ if (ch.type === 0 &&
181
+ ch.parent_id &&
182
+ kimakiCategoryIds.has(ch.parent_id) &&
183
+ !localChannelIds.has(ch.id)) {
184
+ remoteEntries.push({
185
+ channel_id: ch.id,
186
+ directory: '',
187
+ channel_type: 'text',
188
+ guild_id: guildId,
189
+ created_at: null,
190
+ channelName: ch.name,
191
+ guildId,
192
+ deleted: false,
193
+ isLocal: false,
194
+ });
195
+ }
196
+ }
197
+ }
198
+ catch (error) {
199
+ guildScanFailures++;
200
+ cliLogger.warn(`Failed to scan guild ${guildNameMap.get(guildId) || guildId}: ${error instanceof Error ? error.message : String(error)}`);
201
+ }
202
+ }
203
+ if (guildScanFailures === uniqueGuildIds.length) {
204
+ cliLogger.error('Failed to scan all guilds. Check bot permissions or try again.');
205
+ process.exit(EXIT_NO_RESTART);
206
+ }
207
+ }
151
208
  // Build final enriched entries with guild names resolved
152
- const enrichedWithGuild = enriched.map((ch) => ({
209
+ const allEntries = [...enriched, ...remoteEntries];
210
+ const enrichedWithGuild = allEntries.map((ch) => ({
153
211
  ...ch,
154
212
  guildName: ch.guildId ? (guildNameMap.get(ch.guildId) || '') : '',
155
213
  }));
156
214
  // Warn on stderr if the same directory appears in multiple channels (multi-guild duplicates)
157
215
  const directoryCounts = new Map();
158
216
  for (const ch of enrichedWithGuild) {
159
- if (!ch.deleted) {
217
+ if (!ch.deleted && ch.directory) {
160
218
  directoryCounts.set(ch.directory, (directoryCounts.get(ch.directory) || 0) + 1);
161
219
  }
162
220
  }
@@ -168,7 +226,10 @@ cli
168
226
  // Prune stale entries if requested
169
227
  let finalEntries = enrichedWithGuild;
170
228
  if (options.prune) {
171
- const stale = finalEntries.filter((ch) => ch.deleted);
229
+ // Skip default kimaki directory tombstones — those are preserved
230
+ // intentionally so the tutorial channel isn't recreated after deletion.
231
+ const defaultDir = getDefaultKimakiDirectory();
232
+ const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal && ch.directory !== defaultDir);
172
233
  if (stale.length === 0) {
173
234
  cliLogger.log('No stale channels to prune');
174
235
  }
@@ -185,28 +246,39 @@ cli
185
246
  process.exit(0);
186
247
  }
187
248
  }
249
+ if (finalEntries.length === 0) {
250
+ cliLogger.log('No projects registered');
251
+ process.exit(0);
252
+ }
188
253
  if (options.json) {
189
254
  const output = finalEntries.map((ch) => ({
190
255
  channel_id: ch.channel_id,
191
256
  channel_name: ch.channelName,
192
257
  guild_id: ch.guildId,
193
258
  guild_name: ch.guildName,
194
- directory: ch.directory,
195
- folder_name: path.basename(ch.directory),
259
+ directory: ch.directory || null,
260
+ folder_name: ch.directory ? path.basename(ch.directory) : null,
196
261
  deleted: ch.deleted,
262
+ is_local: ch.isLocal,
197
263
  }));
198
264
  console.log(JSON.stringify(output, null, 2));
199
265
  process.exit(0);
200
266
  }
201
267
  for (const ch of finalEntries) {
202
- const folderName = path.basename(ch.directory);
203
268
  const deletedTag = ch.deleted ? ' (deleted from Discord)' : '';
269
+ const remoteTag = !ch.isLocal ? ' [remote]' : '';
204
270
  const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id;
205
271
  const guildLabel = ch.guildName || ch.guildId || '';
206
272
  const guildSuffix = guildLabel ? ` (${guildLabel})` : '';
207
- console.log(`\n${channelLabel}${guildSuffix}${deletedTag}`);
208
- console.log(` Folder: ${folderName}`);
209
- console.log(` Directory: ${ch.directory}`);
273
+ console.log(`\n${channelLabel}${guildSuffix}${deletedTag}${remoteTag}`);
274
+ if (ch.isLocal && ch.directory) {
275
+ const folderName = path.basename(ch.directory);
276
+ console.log(` Folder: ${folderName}`);
277
+ console.log(` Directory: ${ch.directory}`);
278
+ }
279
+ else if (!ch.isLocal) {
280
+ console.log(` (Not registered on this machine)`);
281
+ }
210
282
  console.log(` Channel ID: ${ch.channel_id}`);
211
283
  if (ch.guildId) {
212
284
  console.log(` Guild ID: ${ch.guildId}`);