@otto-assistant/bridge 0.4.93 → 0.4.97
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-account-identity.js +62 -0
- package/dist/anthropic-account-identity.test.js +38 -0
- package/dist/anthropic-auth-plugin.js +88 -16
- package/dist/anthropic-auth-state.js +28 -3
- package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
- package/dist/cli-parsing.test.js +12 -9
- package/dist/cli.js +23 -10
- package/dist/context-awareness-plugin.js +1 -1
- package/dist/context-awareness-plugin.test.js +2 -2
- package/dist/discord-command-registration.js +2 -2
- package/dist/discord-utils.js +5 -2
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/logger.js +8 -2
- package/dist/session-handler/thread-session-runtime.js +18 -1
- package/dist/system-message.js +1 -1
- package/dist/system-message.test.js +1 -1
- package/dist/system-prompt-drift-plugin.js +251 -0
- package/dist/utils.js +5 -1
- package/dist/worktrees.js +0 -33
- package/package.json +2 -1
- package/src/anthropic-account-identity.test.ts +52 -0
- package/src/anthropic-account-identity.ts +77 -0
- package/src/anthropic-auth-plugin.ts +97 -16
- package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
- package/src/anthropic-auth-state.ts +36 -3
- package/src/cli-parsing.test.ts +16 -9
- package/src/cli.ts +29 -11
- package/src/context-awareness-plugin.test.ts +2 -2
- package/src/context-awareness-plugin.ts +1 -1
- package/src/discord-command-registration.ts +2 -2
- package/src/discord-utils.ts +19 -17
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/logger.ts +9 -2
- package/src/session-handler/thread-session-runtime.ts +21 -1
- package/src/system-message.test.ts +1 -1
- package/src/system-message.ts +1 -1
- package/src/system-prompt-drift-plugin.ts +379 -0
- package/src/utils.ts +5 -1
- package/src/worktrees.test.ts +1 -0
- package/src/worktrees.ts +1 -47
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
upsertAccount,
|
|
36
36
|
withAuthStateLock,
|
|
37
37
|
} from './anthropic-auth-state.js'
|
|
38
|
+
import {
|
|
39
|
+
extractAnthropicAccountIdentity,
|
|
40
|
+
type AnthropicAccountIdentity,
|
|
41
|
+
} from './anthropic-account-identity.js'
|
|
38
42
|
// PKCE (Proof Key for Code Exchange) using Web Crypto API.
|
|
39
43
|
// Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
|
|
40
44
|
function base64urlEncode(bytes: Uint8Array): string {
|
|
@@ -68,6 +72,8 @@ const CLIENT_ID = (() => {
|
|
|
68
72
|
|
|
69
73
|
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
70
74
|
const CREATE_API_KEY_URL = 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key'
|
|
75
|
+
const CLIENT_DATA_URL = 'https://api.anthropic.com/api/oauth/claude_cli/client_data'
|
|
76
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
|
|
71
77
|
const CALLBACK_PORT = 53692
|
|
72
78
|
const CALLBACK_PATH = '/callback'
|
|
73
79
|
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`
|
|
@@ -81,6 +87,7 @@ const CLAUDE_CODE_BETA = 'claude-code-20250219'
|
|
|
81
87
|
const OAUTH_BETA = 'oauth-2025-04-20'
|
|
82
88
|
const FINE_GRAINED_TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'
|
|
83
89
|
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
|
|
90
|
+
const TOAST_SESSION_HEADER = 'x-kimaki-session-id'
|
|
84
91
|
|
|
85
92
|
const ANTHROPIC_HOSTS = new Set([
|
|
86
93
|
'api.anthropic.com',
|
|
@@ -298,6 +305,28 @@ async function createApiKey(accessToken: string): Promise<ApiKeySuccess> {
|
|
|
298
305
|
return { type: 'success', key: json.raw_key }
|
|
299
306
|
}
|
|
300
307
|
|
|
308
|
+
async function fetchAnthropicAccountIdentity(accessToken: string) {
|
|
309
|
+
const urls = [CLIENT_DATA_URL, PROFILE_URL]
|
|
310
|
+
for (const url of urls) {
|
|
311
|
+
const responseText = await requestText(url, {
|
|
312
|
+
method: 'GET',
|
|
313
|
+
headers: {
|
|
314
|
+
Accept: 'application/json',
|
|
315
|
+
authorization: `Bearer ${accessToken}`,
|
|
316
|
+
'user-agent': process.env.OPENCODE_ANTHROPIC_USER_AGENT || `claude-cli/${CLAUDE_CODE_VERSION}`,
|
|
317
|
+
'x-app': 'cli',
|
|
318
|
+
},
|
|
319
|
+
}).catch(() => {
|
|
320
|
+
return undefined
|
|
321
|
+
})
|
|
322
|
+
if (!responseText) continue
|
|
323
|
+
const parsed = JSON.parse(responseText) as unknown
|
|
324
|
+
const identity = extractAnthropicAccountIdentity(parsed)
|
|
325
|
+
if (identity) return identity
|
|
326
|
+
}
|
|
327
|
+
return undefined
|
|
328
|
+
}
|
|
329
|
+
|
|
301
330
|
// --- Localhost callback server ---
|
|
302
331
|
|
|
303
332
|
type CallbackResult = { code: string; state: string }
|
|
@@ -469,12 +498,13 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
|
|
|
469
498
|
if (mode === 'apikey') {
|
|
470
499
|
return createApiKey(creds.access)
|
|
471
500
|
}
|
|
501
|
+
const identity = await fetchAnthropicAccountIdentity(creds.access)
|
|
472
502
|
await rememberAnthropicOAuth({
|
|
473
503
|
type: 'oauth',
|
|
474
504
|
refresh: creds.refresh,
|
|
475
505
|
access: creds.access,
|
|
476
506
|
expires: creds.expires,
|
|
477
|
-
})
|
|
507
|
+
}, identity)
|
|
478
508
|
return creds
|
|
479
509
|
}
|
|
480
510
|
|
|
@@ -489,8 +519,7 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
|
|
|
489
519
|
try {
|
|
490
520
|
const result = await waitForCallback(auth.callbackServer)
|
|
491
521
|
return await finalize(result)
|
|
492
|
-
} catch
|
|
493
|
-
console.error(`[anthropic-auth] ${error}`)
|
|
522
|
+
} catch {
|
|
494
523
|
return { type: 'failed' }
|
|
495
524
|
}
|
|
496
525
|
})()
|
|
@@ -509,8 +538,7 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
|
|
|
509
538
|
try {
|
|
510
539
|
const result = await waitForCallback(auth.callbackServer, input)
|
|
511
540
|
return await finalize(result)
|
|
512
|
-
} catch
|
|
513
|
-
console.error(`[anthropic-auth] ${error}`)
|
|
541
|
+
} catch {
|
|
514
542
|
return { type: 'failed' }
|
|
515
543
|
}
|
|
516
544
|
})()
|
|
@@ -528,17 +556,26 @@ function toClaudeCodeToolName(name: string) {
|
|
|
528
556
|
return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name
|
|
529
557
|
}
|
|
530
558
|
|
|
531
|
-
function sanitizeSystemText(text: string) {
|
|
532
|
-
|
|
559
|
+
function sanitizeSystemText(text: string, onError?: (msg: string) => void) {
|
|
560
|
+
const startIdx = text.indexOf(OPENCODE_IDENTITY)
|
|
561
|
+
if (startIdx === -1) return text
|
|
562
|
+
const codeRefsMarker = '# Code References'
|
|
563
|
+
const endIdx = text.indexOf(codeRefsMarker, startIdx)
|
|
564
|
+
if (endIdx === -1) {
|
|
565
|
+
onError?.(`sanitizeSystemText: could not find '# Code References' after OpenCode identity`)
|
|
566
|
+
return text
|
|
567
|
+
}
|
|
568
|
+
// Remove everything from the OpenCode identity up to (but not including) '# Code References'
|
|
569
|
+
return text.slice(0, startIdx) + text.slice(endIdx)
|
|
533
570
|
}
|
|
534
571
|
|
|
535
|
-
function prependClaudeCodeIdentity(system: unknown) {
|
|
572
|
+
function prependClaudeCodeIdentity(system: unknown, onError?: (msg: string) => void) {
|
|
536
573
|
const identityBlock = { type: 'text', text: CLAUDE_CODE_IDENTITY }
|
|
537
574
|
|
|
538
575
|
if (typeof system === 'undefined') return [identityBlock]
|
|
539
576
|
|
|
540
577
|
if (typeof system === 'string') {
|
|
541
|
-
const sanitized = sanitizeSystemText(system)
|
|
578
|
+
const sanitized = sanitizeSystemText(system, onError)
|
|
542
579
|
if (sanitized === CLAUDE_CODE_IDENTITY) return [identityBlock]
|
|
543
580
|
return [identityBlock, { type: 'text', text: sanitized }]
|
|
544
581
|
}
|
|
@@ -546,11 +583,11 @@ function prependClaudeCodeIdentity(system: unknown) {
|
|
|
546
583
|
if (!Array.isArray(system)) return [identityBlock, system]
|
|
547
584
|
|
|
548
585
|
const sanitized = system.map((item) => {
|
|
549
|
-
if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item) }
|
|
586
|
+
if (typeof item === 'string') return { type: 'text', text: sanitizeSystemText(item, onError) }
|
|
550
587
|
if (item && typeof item === 'object' && (item as { type?: unknown }).type === 'text') {
|
|
551
588
|
const text = (item as { text?: unknown }).text
|
|
552
589
|
if (typeof text === 'string') {
|
|
553
|
-
return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text) }
|
|
590
|
+
return { ...(item as Record<string, unknown>), text: sanitizeSystemText(text, onError) }
|
|
554
591
|
}
|
|
555
592
|
}
|
|
556
593
|
return item
|
|
@@ -568,7 +605,7 @@ function prependClaudeCodeIdentity(system: unknown) {
|
|
|
568
605
|
return [identityBlock, ...sanitized]
|
|
569
606
|
}
|
|
570
607
|
|
|
571
|
-
function rewriteRequestPayload(body: string | undefined) {
|
|
608
|
+
function rewriteRequestPayload(body: string | undefined, onError?: (msg: string) => void) {
|
|
572
609
|
if (!body) return { body, modelId: undefined, reverseToolNameMap: new Map<string, string>() }
|
|
573
610
|
|
|
574
611
|
try {
|
|
@@ -589,7 +626,7 @@ function rewriteRequestPayload(body: string | undefined) {
|
|
|
589
626
|
}
|
|
590
627
|
|
|
591
628
|
// Rename system prompt
|
|
592
|
-
payload.system = prependClaudeCodeIdentity(payload.system)
|
|
629
|
+
payload.system = prependClaudeCodeIdentity(payload.system, onError)
|
|
593
630
|
|
|
594
631
|
// Rename tool_choice
|
|
595
632
|
if (
|
|
@@ -673,6 +710,19 @@ function wrapResponseStream(response: Response, reverseToolNameMap: Map<string,
|
|
|
673
710
|
})
|
|
674
711
|
}
|
|
675
712
|
|
|
713
|
+
function appendToastSessionMarker({
|
|
714
|
+
message,
|
|
715
|
+
sessionId,
|
|
716
|
+
}: {
|
|
717
|
+
message: string
|
|
718
|
+
sessionId: string | undefined
|
|
719
|
+
}) {
|
|
720
|
+
if (!sessionId) {
|
|
721
|
+
return message
|
|
722
|
+
}
|
|
723
|
+
return `${message} ${sessionId}`
|
|
724
|
+
}
|
|
725
|
+
|
|
676
726
|
// --- Beta headers ---
|
|
677
727
|
|
|
678
728
|
function getRequiredBetas(modelId: string | undefined) {
|
|
@@ -728,7 +778,18 @@ async function getFreshOAuth(
|
|
|
728
778
|
await setAnthropicAuth(refreshed, client)
|
|
729
779
|
const store = await loadAccountStore()
|
|
730
780
|
if (store.accounts.length > 0) {
|
|
731
|
-
|
|
781
|
+
const identity: AnthropicAccountIdentity | undefined = (() => {
|
|
782
|
+
const currentIndex = store.accounts.findIndex((account) => {
|
|
783
|
+
return account.refresh === latest.refresh || account.access === latest.access
|
|
784
|
+
})
|
|
785
|
+
const current = currentIndex >= 0 ? store.accounts[currentIndex] : undefined
|
|
786
|
+
if (!current) return undefined
|
|
787
|
+
return {
|
|
788
|
+
...(current.email ? { email: current.email } : {}),
|
|
789
|
+
...(current.accountId ? { accountId: current.accountId } : {}),
|
|
790
|
+
}
|
|
791
|
+
})()
|
|
792
|
+
upsertAccount(store, { ...refreshed, ...identity })
|
|
732
793
|
await saveAccountStore(store)
|
|
733
794
|
}
|
|
734
795
|
return refreshed
|
|
@@ -743,6 +804,12 @@ async function getFreshOAuth(
|
|
|
743
804
|
|
|
744
805
|
const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
745
806
|
return {
|
|
807
|
+
'chat.headers': async (input, output) => {
|
|
808
|
+
if (input.model.providerID !== 'anthropic') {
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
output.headers[TOAST_SESSION_HEADER] = input.sessionID
|
|
812
|
+
},
|
|
746
813
|
auth: {
|
|
747
814
|
provider: 'anthropic',
|
|
748
815
|
async loader(
|
|
@@ -779,17 +846,27 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
779
846
|
.catch(() => undefined)
|
|
780
847
|
: undefined
|
|
781
848
|
|
|
782
|
-
const rewritten = rewriteRequestPayload(originalBody)
|
|
783
849
|
const headers = new Headers(init?.headers)
|
|
784
850
|
if (input instanceof Request) {
|
|
785
851
|
input.headers.forEach((v, k) => {
|
|
786
852
|
if (!headers.has(k)) headers.set(k, v)
|
|
787
853
|
})
|
|
788
854
|
}
|
|
855
|
+
const sessionId = headers.get(TOAST_SESSION_HEADER) ?? undefined
|
|
856
|
+
|
|
857
|
+
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
858
|
+
client.tui.showToast({
|
|
859
|
+
body: {
|
|
860
|
+
message: appendToastSessionMarker({ message: msg, sessionId }),
|
|
861
|
+
variant: 'error',
|
|
862
|
+
},
|
|
863
|
+
}).catch(() => {})
|
|
864
|
+
})
|
|
789
865
|
const betas = getRequiredBetas(rewritten.modelId)
|
|
790
866
|
|
|
791
867
|
const runRequest = async (auth: OAuthStored) => {
|
|
792
868
|
const requestHeaders = new Headers(headers)
|
|
869
|
+
requestHeaders.delete(TOAST_SESSION_HEADER)
|
|
793
870
|
requestHeaders.set('accept', 'application/json')
|
|
794
871
|
requestHeaders.set(
|
|
795
872
|
'anthropic-beta',
|
|
@@ -826,9 +903,13 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
826
903
|
// Show toast notification so Discord thread shows the rotation
|
|
827
904
|
client.tui.showToast({
|
|
828
905
|
body: {
|
|
829
|
-
message:
|
|
906
|
+
message: appendToastSessionMarker({
|
|
907
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
908
|
+
sessionId,
|
|
909
|
+
}),
|
|
830
910
|
variant: 'info',
|
|
831
911
|
},
|
|
912
|
+
|
|
832
913
|
}).catch(() => {})
|
|
833
914
|
const retryAuth = await getFreshOAuth(getAuth, client)
|
|
834
915
|
if (retryAuth) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// Tests
|
|
1
|
+
// Tests Anthropic OAuth account persistence, deduplication, and rotation.
|
|
2
2
|
|
|
3
3
|
import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
|
7
7
|
import {
|
|
8
|
+
accountLabel,
|
|
8
9
|
authFilePath,
|
|
9
10
|
loadAccountStore,
|
|
10
11
|
rememberAnthropicOAuth,
|
|
@@ -60,6 +61,27 @@ describe('rememberAnthropicOAuth', () => {
|
|
|
60
61
|
expires: 3,
|
|
61
62
|
})
|
|
62
63
|
})
|
|
64
|
+
|
|
65
|
+
test('deduplicates new tokens by email or account ID', async () => {
|
|
66
|
+
await rememberAnthropicOAuth(firstAccount, {
|
|
67
|
+
email: 'user@example.com',
|
|
68
|
+
accountId: 'usr_123',
|
|
69
|
+
})
|
|
70
|
+
await rememberAnthropicOAuth(secondAccount, {
|
|
71
|
+
email: 'User@example.com',
|
|
72
|
+
accountId: 'usr_123',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const store = await loadAccountStore()
|
|
76
|
+
expect(store.accounts).toHaveLength(1)
|
|
77
|
+
expect(store.accounts[0]).toMatchObject({
|
|
78
|
+
refresh: 'refresh-second',
|
|
79
|
+
access: 'access-second',
|
|
80
|
+
email: 'user@example.com',
|
|
81
|
+
accountId: 'usr_123',
|
|
82
|
+
})
|
|
83
|
+
expect(accountLabel(store.accounts[0]!)).toBe('user@example.com')
|
|
84
|
+
})
|
|
63
85
|
})
|
|
64
86
|
|
|
65
87
|
describe('rotateAnthropicAccount', () => {
|
|
@@ -2,6 +2,10 @@ import type { Plugin } from '@opencode-ai/plugin'
|
|
|
2
2
|
import * as fs from 'node:fs/promises'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
normalizeAnthropicAccountIdentity,
|
|
7
|
+
type AnthropicAccountIdentity,
|
|
8
|
+
} from './anthropic-account-identity.js'
|
|
5
9
|
|
|
6
10
|
const AUTH_LOCK_STALE_MS = 30_000
|
|
7
11
|
const AUTH_LOCK_RETRY_MS = 100
|
|
@@ -14,6 +18,8 @@ export type OAuthStored = {
|
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
type AccountRecord = OAuthStored & {
|
|
21
|
+
email?: string
|
|
22
|
+
accountId?: string
|
|
17
23
|
addedAt: number
|
|
18
24
|
lastUsed: number
|
|
19
25
|
}
|
|
@@ -114,6 +120,8 @@ export function normalizeAccountStore(
|
|
|
114
120
|
typeof account.refresh === 'string' &&
|
|
115
121
|
typeof account.access === 'string' &&
|
|
116
122
|
typeof account.expires === 'number' &&
|
|
123
|
+
(typeof account.email === 'undefined' || typeof account.email === 'string') &&
|
|
124
|
+
(typeof account.accountId === 'undefined' || typeof account.accountId === 'string') &&
|
|
117
125
|
typeof account.addedAt === 'number' &&
|
|
118
126
|
typeof account.lastUsed === 'number',
|
|
119
127
|
)
|
|
@@ -135,8 +143,13 @@ export async function saveAccountStore(store: AccountStore) {
|
|
|
135
143
|
|
|
136
144
|
/** Short label for an account: first 8 + last 4 chars of refresh token. */
|
|
137
145
|
export function accountLabel(account: OAuthStored, index?: number): string {
|
|
146
|
+
const accountWithIdentity = account as OAuthStored & AnthropicAccountIdentity
|
|
147
|
+
const identity = accountWithIdentity.email || accountWithIdentity.accountId
|
|
138
148
|
const r = account.refresh
|
|
139
149
|
const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r
|
|
150
|
+
if (identity) {
|
|
151
|
+
return index !== undefined ? `#${index + 1} (${identity})` : identity
|
|
152
|
+
}
|
|
140
153
|
return index !== undefined ? `#${index + 1} (${short})` : short
|
|
141
154
|
}
|
|
142
155
|
|
|
@@ -162,14 +175,29 @@ function findCurrentAccountIndex(store: AccountStore, auth: OAuthStored) {
|
|
|
162
175
|
}
|
|
163
176
|
|
|
164
177
|
export function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date.now()) {
|
|
178
|
+
const authWithIdentity = auth as OAuthStored & AnthropicAccountIdentity
|
|
179
|
+
const identity = normalizeAnthropicAccountIdentity({
|
|
180
|
+
email: authWithIdentity.email,
|
|
181
|
+
accountId: authWithIdentity.accountId,
|
|
182
|
+
})
|
|
165
183
|
const index = store.accounts.findIndex((account) => {
|
|
166
|
-
|
|
184
|
+
if (account.refresh === auth.refresh || account.access === auth.access) {
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
if (identity?.accountId && account.accountId === identity.accountId) {
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
if (identity?.email && account.email === identity.email) {
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
return false
|
|
167
194
|
})
|
|
168
195
|
const nextAccount: AccountRecord = {
|
|
169
196
|
type: 'oauth',
|
|
170
197
|
refresh: auth.refresh,
|
|
171
198
|
access: auth.access,
|
|
172
199
|
expires: auth.expires,
|
|
200
|
+
...identity,
|
|
173
201
|
addedAt: now,
|
|
174
202
|
lastUsed: now,
|
|
175
203
|
}
|
|
@@ -186,15 +214,20 @@ export function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date
|
|
|
186
214
|
...existing,
|
|
187
215
|
...nextAccount,
|
|
188
216
|
addedAt: existing.addedAt,
|
|
217
|
+
email: nextAccount.email || existing.email,
|
|
218
|
+
accountId: nextAccount.accountId || existing.accountId,
|
|
189
219
|
}
|
|
190
220
|
store.activeIndex = index
|
|
191
221
|
return index
|
|
192
222
|
}
|
|
193
223
|
|
|
194
|
-
export async function rememberAnthropicOAuth(
|
|
224
|
+
export async function rememberAnthropicOAuth(
|
|
225
|
+
auth: OAuthStored,
|
|
226
|
+
identity?: AnthropicAccountIdentity,
|
|
227
|
+
) {
|
|
195
228
|
await withAuthStateLock(async () => {
|
|
196
229
|
const store = await loadAccountStore()
|
|
197
|
-
upsertAccount(store, auth)
|
|
230
|
+
upsertAccount(store, { ...auth, ...normalizeAnthropicAccountIdentity(identity) })
|
|
198
231
|
await saveAccountStore(store)
|
|
199
232
|
})
|
|
200
233
|
}
|
package/src/cli-parsing.test.ts
CHANGED
|
@@ -27,8 +27,8 @@ function createCliForIdParsing() {
|
|
|
27
27
|
.option('-g, --guild <guildId>', 'Discord guild/server ID')
|
|
28
28
|
|
|
29
29
|
cli.command('task delete <id>', 'Delete task')
|
|
30
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
31
|
-
cli.command('anthropic-accounts remove <
|
|
30
|
+
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
31
|
+
cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account')
|
|
32
32
|
|
|
33
33
|
return cli
|
|
34
34
|
}
|
|
@@ -163,19 +163,26 @@ describe('goke CLI ID parsing', () => {
|
|
|
163
163
|
expect(typeof result.args[0]).toBe('string')
|
|
164
164
|
})
|
|
165
165
|
|
|
166
|
-
test('
|
|
166
|
+
test('anthropic account remove parses index and email as strings', () => {
|
|
167
167
|
const cli = createCliForIdParsing()
|
|
168
168
|
|
|
169
|
-
const
|
|
169
|
+
const indexResult = cli.parse(
|
|
170
170
|
['node', 'kimaki', 'anthropic-accounts', 'remove', '2'],
|
|
171
171
|
{ run: false },
|
|
172
172
|
)
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
const emailResult = cli.parse(
|
|
175
|
+
['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com'],
|
|
176
|
+
{ run: false },
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(indexResult.args[0]).toBe('2')
|
|
180
|
+
expect(typeof indexResult.args[0]).toBe('string')
|
|
181
|
+
expect(emailResult.args[0]).toBe('user@example.com')
|
|
182
|
+
expect(typeof emailResult.args[0]).toBe('string')
|
|
176
183
|
})
|
|
177
184
|
|
|
178
|
-
test('
|
|
185
|
+
test('anthropic account commands are included in help output', () => {
|
|
179
186
|
const stdout = {
|
|
180
187
|
text: '',
|
|
181
188
|
write(data: string | Uint8Array) {
|
|
@@ -185,11 +192,11 @@ describe('goke CLI ID parsing', () => {
|
|
|
185
192
|
|
|
186
193
|
const cli = goke('kimaki', { stdout: stdout as never })
|
|
187
194
|
cli.command('send', 'Send a message')
|
|
188
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
195
|
+
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
189
196
|
cli.help()
|
|
190
197
|
cli.parse(['node', 'kimaki', '--help'], { run: false })
|
|
191
198
|
|
|
192
199
|
expect(stdout.text).toContain('send')
|
|
193
|
-
expect(stdout.text).
|
|
200
|
+
expect(stdout.text).toContain('anthropic-accounts')
|
|
194
201
|
})
|
|
195
202
|
})
|
package/src/cli.ts
CHANGED
|
@@ -1024,7 +1024,8 @@ async function resolveCredentials({
|
|
|
1024
1024
|
options: [
|
|
1025
1025
|
{
|
|
1026
1026
|
value: 'gateway' as const,
|
|
1027
|
-
|
|
1027
|
+
disabled: true,
|
|
1028
|
+
label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
|
|
1028
1029
|
},
|
|
1029
1030
|
{
|
|
1030
1031
|
value: 'self_hosted' as const,
|
|
@@ -3168,7 +3169,6 @@ cli
|
|
|
3168
3169
|
'anthropic-accounts list',
|
|
3169
3170
|
'List stored Anthropic OAuth accounts used for automatic rotation',
|
|
3170
3171
|
)
|
|
3171
|
-
.hidden()
|
|
3172
3172
|
.action(async () => {
|
|
3173
3173
|
const store = await loadAccountStore()
|
|
3174
3174
|
console.log(`Store: ${accountsFilePath()}`)
|
|
@@ -3187,19 +3187,37 @@ cli
|
|
|
3187
3187
|
|
|
3188
3188
|
cli
|
|
3189
3189
|
.command(
|
|
3190
|
-
'anthropic-accounts remove <
|
|
3191
|
-
'Remove a stored Anthropic OAuth account from the rotation pool',
|
|
3190
|
+
'anthropic-accounts remove <indexOrEmail>',
|
|
3191
|
+
'Remove a stored Anthropic OAuth account from the rotation pool by index or email',
|
|
3192
3192
|
)
|
|
3193
|
-
.
|
|
3194
|
-
|
|
3195
|
-
const
|
|
3196
|
-
|
|
3197
|
-
|
|
3193
|
+
.action(async (indexOrEmail: string) => {
|
|
3194
|
+
const value = Number(indexOrEmail)
|
|
3195
|
+
const store = await loadAccountStore()
|
|
3196
|
+
const resolvedIndex = (() => {
|
|
3197
|
+
if (Number.isInteger(value) && value >= 1) {
|
|
3198
|
+
return value - 1
|
|
3199
|
+
}
|
|
3200
|
+
const email = indexOrEmail.trim().toLowerCase()
|
|
3201
|
+
if (!email) {
|
|
3202
|
+
return -1
|
|
3203
|
+
}
|
|
3204
|
+
return store.accounts.findIndex((account) => {
|
|
3205
|
+
return account.email?.toLowerCase() === email
|
|
3206
|
+
})
|
|
3207
|
+
})()
|
|
3208
|
+
|
|
3209
|
+
if (resolvedIndex < 0) {
|
|
3210
|
+
cliLogger.error(
|
|
3211
|
+
'Usage: kimaki anthropic-accounts remove <index-or-email>',
|
|
3212
|
+
)
|
|
3198
3213
|
process.exit(EXIT_NO_RESTART)
|
|
3199
3214
|
}
|
|
3200
3215
|
|
|
3201
|
-
|
|
3202
|
-
|
|
3216
|
+
const removed = store.accounts[resolvedIndex]
|
|
3217
|
+
await removeAccount(resolvedIndex)
|
|
3218
|
+
cliLogger.log(
|
|
3219
|
+
`Removed Anthropic account ${removed ? accountLabel(removed, resolvedIndex) : indexOrEmail}`,
|
|
3220
|
+
)
|
|
3203
3221
|
process.exit(0)
|
|
3204
3222
|
})
|
|
3205
3223
|
|
|
@@ -46,7 +46,7 @@ describe('shouldInjectPwd', () => {
|
|
|
46
46
|
{
|
|
47
47
|
"inject": true,
|
|
48
48
|
"text": "
|
|
49
|
-
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You
|
|
49
|
+
[working directory changed. Previous working directory: /repo/main. Current working directory: /repo/worktree. You should read, write, and edit files under /repo/worktree. Do NOT read, write, or edit files under /repo/main.]",
|
|
50
50
|
}
|
|
51
51
|
`)
|
|
52
52
|
})
|
|
@@ -62,7 +62,7 @@ describe('shouldInjectPwd', () => {
|
|
|
62
62
|
{
|
|
63
63
|
"inject": true,
|
|
64
64
|
"text": "
|
|
65
|
-
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You
|
|
65
|
+
[working directory changed. Previous working directory: /repo/worktree-a. Current working directory: /repo/worktree-b. You should read, write, and edit files under /repo/worktree-b. Do NOT read, write, or edit files under /repo/worktree-a.]",
|
|
66
66
|
}
|
|
67
67
|
`)
|
|
68
68
|
})
|
|
@@ -126,7 +126,7 @@ export function shouldInjectPwd({
|
|
|
126
126
|
text:
|
|
127
127
|
`\n[working directory changed. Previous working directory: ${priorDirectory}. ` +
|
|
128
128
|
`Current working directory: ${currentDir}. ` +
|
|
129
|
-
`You
|
|
129
|
+
`You should read, write, and edit files under ${currentDir}. ` +
|
|
130
130
|
`Do NOT read, write, or edit files under ${priorDirectory}.]`,
|
|
131
131
|
}
|
|
132
132
|
}
|
|
@@ -182,7 +182,7 @@ export async function registerCommands({
|
|
|
182
182
|
new SlashCommandBuilder()
|
|
183
183
|
.setName('new-worktree')
|
|
184
184
|
.setDescription(
|
|
185
|
-
truncateCommandDescription('Create a git worktree branch from
|
|
185
|
+
truncateCommandDescription('Create a git worktree branch from HEAD by default. Optionally pick a base branch.'),
|
|
186
186
|
)
|
|
187
187
|
.addStringOption((option) => {
|
|
188
188
|
option
|
|
@@ -198,7 +198,7 @@ export async function registerCommands({
|
|
|
198
198
|
option
|
|
199
199
|
.setName('base-branch')
|
|
200
200
|
.setDescription(
|
|
201
|
-
truncateCommandDescription('Branch to create the worktree from (default:
|
|
201
|
+
truncateCommandDescription('Branch to create the worktree from (default: HEAD)'),
|
|
202
202
|
)
|
|
203
203
|
.setRequired(false)
|
|
204
204
|
.setAutocomplete(true)
|
package/src/discord-utils.ts
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
|
|
3
3
|
// thread message sending, and channel metadata extraction from topic tags.
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
// Use namespace import for CJS interop — discord.js is CJS and its named
|
|
6
|
+
// exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
|
|
7
|
+
// discord.js uses tslib's __exportStar which is opaque to static analysis.
|
|
8
|
+
import * as discord from 'discord.js'
|
|
9
|
+
import type {
|
|
10
|
+
APIInteractionGuildMember,
|
|
11
|
+
AutocompleteInteraction,
|
|
12
|
+
GuildMember as GuildMemberType,
|
|
13
|
+
Guild,
|
|
14
|
+
Message,
|
|
15
|
+
REST as RESTType,
|
|
16
|
+
TextChannel,
|
|
17
|
+
ThreadChannel,
|
|
16
18
|
} from 'discord.js'
|
|
17
|
-
|
|
19
|
+
const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord
|
|
18
20
|
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
|
|
19
21
|
import { discordApiUrl } from './discord-urls.js'
|
|
20
22
|
import { Lexer } from 'marked'
|
|
@@ -37,7 +39,7 @@ const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
|
37
39
|
* Returns false if member is null or has the "no-kimaki" role (overrides all).
|
|
38
40
|
*/
|
|
39
41
|
export function hasKimakiBotPermission(
|
|
40
|
-
member:
|
|
42
|
+
member: GuildMemberType | APIInteractionGuildMember | null,
|
|
41
43
|
guild?: Guild | null,
|
|
42
44
|
): boolean {
|
|
43
45
|
if (!member) {
|
|
@@ -61,7 +63,7 @@ export function hasKimakiBotPermission(
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function hasRoleByName(
|
|
64
|
-
member:
|
|
66
|
+
member: GuildMemberType | APIInteractionGuildMember,
|
|
65
67
|
roleName: string,
|
|
66
68
|
guild?: Guild | null,
|
|
67
69
|
): boolean {
|
|
@@ -89,7 +91,7 @@ function hasRoleByName(
|
|
|
89
91
|
* Check if the member has the "no-kimaki" role that blocks bot access.
|
|
90
92
|
* Separate from hasKimakiBotPermission so callers can show a specific error message.
|
|
91
93
|
*/
|
|
92
|
-
export function hasNoKimakiRole(member:
|
|
94
|
+
export function hasNoKimakiRole(member: GuildMemberType | null): boolean {
|
|
93
95
|
if (!member?.roles?.cache) {
|
|
94
96
|
return false
|
|
95
97
|
}
|
|
@@ -108,7 +110,7 @@ export async function reactToThread({
|
|
|
108
110
|
channelId,
|
|
109
111
|
emoji,
|
|
110
112
|
}: {
|
|
111
|
-
rest:
|
|
113
|
+
rest: RESTType
|
|
112
114
|
threadId: string
|
|
113
115
|
/** Parent channel ID where the thread starter message lives.
|
|
114
116
|
* If not provided, fetches the thread info from Discord API to resolve it. */
|
|
@@ -169,7 +171,7 @@ export async function archiveThread({
|
|
|
169
171
|
client,
|
|
170
172
|
archiveDelay = 0,
|
|
171
173
|
}: {
|
|
172
|
-
rest:
|
|
174
|
+
rest: RESTType
|
|
173
175
|
threadId: string
|
|
174
176
|
parentChannelId?: string
|
|
175
177
|
sessionId?: string
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
export { ipcToolsPlugin } from './ipc-tools-plugin.js'
|
|
13
13
|
export { contextAwarenessPlugin } from './context-awareness-plugin.js'
|
|
14
14
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
|
|
15
|
+
export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js'
|
|
15
16
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js'
|
|
16
17
|
export { imageOptimizerPlugin } from './image-optimizer-plugin.js'
|
|
17
18
|
export { kittyGraphicsPlugin } from 'kitty-graphics-agent'
|
package/src/logger.ts
CHANGED
|
@@ -95,12 +95,19 @@ export function getLogFilePath(): string | null {
|
|
|
95
95
|
return logFilePath
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
const MAX_LOG_ARG_LENGTH = 1000
|
|
99
|
+
|
|
100
|
+
function truncate(str: string, max: number): string {
|
|
101
|
+
if (str.length <= max) return str
|
|
102
|
+
return str.slice(0, max) + `… [truncated ${str.length - max} chars]`
|
|
103
|
+
}
|
|
104
|
+
|
|
98
105
|
function formatArg(arg: unknown): string {
|
|
99
106
|
if (typeof arg === 'string') {
|
|
100
|
-
return sanitizeSensitiveText(arg, { redactPaths: false })
|
|
107
|
+
return truncate(sanitizeSensitiveText(arg, { redactPaths: false }), MAX_LOG_ARG_LENGTH)
|
|
101
108
|
}
|
|
102
109
|
const safeArg = sanitizeUnknownValue(arg, { redactPaths: false })
|
|
103
|
-
return util.inspect(safeArg, { colors: true, depth: 4 })
|
|
110
|
+
return truncate(util.inspect(safeArg, { colors: true, depth: 4 }), MAX_LOG_ARG_LENGTH)
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
export function formatErrorWithStack(error: unknown): string {
|