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.
- package/dist/anthropic-auth-plugin.js +78 -33
- package/dist/anthropic-auth-state.js +62 -27
- package/dist/anthropic-auth-state.test.js +55 -2
- package/dist/channel-management.js +29 -6
- package/dist/cli-commands/project.js +87 -15
- package/dist/cli-commands/send.js +43 -3
- package/dist/cli-runner.js +57 -1
- package/dist/commands/btw.js +4 -3
- package/dist/commands/login.js +1 -1
- package/dist/commands/new-worktree.js +23 -5
- package/dist/commands/queue.js +12 -2
- package/dist/commands/session.js +66 -13
- package/dist/commands/tasks.js +10 -3
- package/dist/commands/user-command.js +3 -2
- package/dist/database.js +7 -8
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +47 -8
- package/dist/hrana-server.js +19 -0
- package/dist/message-preprocessing.js +9 -2
- package/dist/oauth-rotation-shared.js +21 -0
- package/dist/opencode.js +73 -4
- package/dist/schema.js +3 -0
- package/dist/session-handler/global-event-listener.js +38 -6
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime.js +19 -3
- package/dist/system-message.js +52 -26
- package/dist/system-message.test.js +49 -26
- package/dist/task-runner.js +8 -4
- package/dist/worktree-lifecycle.e2e.test.js +84 -1
- package/dist/worktrees.js +63 -0
- package/package.json +3 -3
- package/skills/holocron/SKILL.md +34 -8
- package/src/anthropic-auth-plugin.ts +82 -35
- package/src/anthropic-auth-state.test.ts +73 -1
- package/src/anthropic-auth-state.ts +85 -25
- package/src/channel-management.ts +35 -6
- package/src/cli-commands/project.ts +105 -17
- package/src/cli-commands/send.ts +56 -5
- package/src/cli-runner.ts +81 -0
- package/src/commands/btw.ts +6 -2
- package/src/commands/login.ts +1 -1
- package/src/commands/new-worktree.ts +28 -4
- package/src/commands/queue.ts +13 -2
- package/src/commands/session.ts +79 -17
- package/src/commands/tasks.ts +11 -3
- package/src/commands/user-command.ts +3 -2
- package/src/database.ts +8 -9
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +54 -7
- package/src/hrana-server.ts +19 -0
- package/src/message-preprocessing.ts +11 -2
- package/src/oauth-rotation-shared.ts +22 -0
- package/src/opencode.ts +90 -6
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/global-event-listener.ts +38 -6
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +25 -3
- package/src/system-message.test.ts +49 -26
- package/src/system-message.ts +52 -26
- package/src/task-runner.ts +8 -4
- package/src/worktree-lifecycle.e2e.test.ts +104 -1
- 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
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
if (!
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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 =
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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}`);
|