kimaki 0.4.96 → 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 +70 -12
- package/dist/anthropic-auth-state.js +28 -3
- package/dist/anthropic-auth-state.test.js +150 -0
- package/dist/cli-parsing.test.js +12 -9
- package/dist/cli.js +23 -10
- package/dist/discord-command-registration.js +2 -2
- package/dist/system-message.js +1 -1
- package/dist/system-message.test.js +1 -1
- package/dist/worktrees.js +0 -33
- package/package.json +6 -6
- package/src/anthropic-account-identity.test.ts +52 -0
- package/src/anthropic-account-identity.ts +77 -0
- package/src/anthropic-auth-plugin.ts +79 -12
- 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/discord-command-registration.ts +2 -2
- package/src/system-message.test.ts +1 -1
- package/src/system-message.ts +1 -1
- package/src/worktrees.test.ts +1 -0
- package/src/worktrees.ts +1 -47
package/dist/worktrees.js
CHANGED
|
@@ -394,39 +394,6 @@ async function validateSubmodulePointers(directory) {
|
|
|
394
394
|
return new Error(`Submodule validation failed: ${validationIssues.join('; ')}`);
|
|
395
395
|
}
|
|
396
396
|
async function resolveDefaultWorktreeTarget(directory) {
|
|
397
|
-
const remoteHead = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
|
|
398
|
-
cwd: directory,
|
|
399
|
-
}).catch(() => {
|
|
400
|
-
return null;
|
|
401
|
-
});
|
|
402
|
-
const remoteRef = remoteHead?.stdout.trim();
|
|
403
|
-
if (remoteRef?.startsWith('refs/remotes/')) {
|
|
404
|
-
return remoteRef.replace('refs/remotes/', '');
|
|
405
|
-
}
|
|
406
|
-
const hasMain = await execAsync('git show-ref --verify --quiet refs/heads/main', {
|
|
407
|
-
cwd: directory,
|
|
408
|
-
})
|
|
409
|
-
.then(() => {
|
|
410
|
-
return true;
|
|
411
|
-
})
|
|
412
|
-
.catch(() => {
|
|
413
|
-
return false;
|
|
414
|
-
});
|
|
415
|
-
if (hasMain) {
|
|
416
|
-
return 'main';
|
|
417
|
-
}
|
|
418
|
-
const hasMaster = await execAsync('git show-ref --verify --quiet refs/heads/master', {
|
|
419
|
-
cwd: directory,
|
|
420
|
-
})
|
|
421
|
-
.then(() => {
|
|
422
|
-
return true;
|
|
423
|
-
})
|
|
424
|
-
.catch(() => {
|
|
425
|
-
return false;
|
|
426
|
-
});
|
|
427
|
-
if (hasMaster) {
|
|
428
|
-
return 'master';
|
|
429
|
-
}
|
|
430
397
|
return 'HEAD';
|
|
431
398
|
}
|
|
432
399
|
function getManagedWorktreeDirectory({ directory, name, }) {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.97",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"prisma": "7.4.2",
|
|
26
26
|
"tsx": "^4.20.5",
|
|
27
27
|
"undici": "^8.0.2",
|
|
28
|
-
"opencode-cached-provider": "^0.0.1",
|
|
29
|
-
"discord-digital-twin": "^0.1.0",
|
|
30
28
|
"db": "^0.0.0",
|
|
29
|
+
"discord-digital-twin": "^0.1.0",
|
|
30
|
+
"opencode-cached-provider": "^0.0.1",
|
|
31
31
|
"opencode-deterministic-provider": "^0.0.1"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
@@ -64,10 +64,10 @@
|
|
|
64
64
|
"yaml": "^2.8.3",
|
|
65
65
|
"zod": "^4.3.6",
|
|
66
66
|
"zustand": "^5.0.11",
|
|
67
|
-
"errore": "^0.14.1",
|
|
68
67
|
"traforo": "^0.2.4",
|
|
69
|
-
"
|
|
70
|
-
"libsqlproxy": "^0.1.0"
|
|
68
|
+
"errore": "^0.14.1",
|
|
69
|
+
"libsqlproxy": "^0.1.0",
|
|
70
|
+
"opencode-injection-guard": "^0.2.1"
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
73
|
"@snazzah/davey": "^0.1.10",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Tests Anthropic OAuth account identity parsing and normalization.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
extractAnthropicAccountIdentity,
|
|
6
|
+
normalizeAnthropicAccountIdentity,
|
|
7
|
+
} from './anthropic-account-identity.js'
|
|
8
|
+
|
|
9
|
+
describe('normalizeAnthropicAccountIdentity', () => {
|
|
10
|
+
test('normalizes email casing and drops empty values', () => {
|
|
11
|
+
expect(
|
|
12
|
+
normalizeAnthropicAccountIdentity({
|
|
13
|
+
email: ' User@Example.com ',
|
|
14
|
+
accountId: ' user_123 ',
|
|
15
|
+
}),
|
|
16
|
+
).toEqual({
|
|
17
|
+
email: 'user@example.com',
|
|
18
|
+
accountId: 'user_123',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(normalizeAnthropicAccountIdentity({ email: ' ' })).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('extractAnthropicAccountIdentity', () => {
|
|
26
|
+
test('prefers nested user profile identity from client_data responses', () => {
|
|
27
|
+
expect(
|
|
28
|
+
extractAnthropicAccountIdentity({
|
|
29
|
+
organizations: [{ id: 'org_123', name: 'Workspace' }],
|
|
30
|
+
user: {
|
|
31
|
+
id: 'usr_123',
|
|
32
|
+
email: 'User@Example.com',
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
).toEqual({
|
|
36
|
+
accountId: 'usr_123',
|
|
37
|
+
email: 'user@example.com',
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('falls back to profile-style payloads without email', () => {
|
|
42
|
+
expect(
|
|
43
|
+
extractAnthropicAccountIdentity({
|
|
44
|
+
profile: {
|
|
45
|
+
user_id: 'usr_456',
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
).toEqual({
|
|
49
|
+
accountId: 'usr_456',
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Helpers for extracting and normalizing Anthropic OAuth account identity.
|
|
2
|
+
|
|
3
|
+
export type AnthropicAccountIdentity = {
|
|
4
|
+
email?: string
|
|
5
|
+
accountId?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type IdentityCandidate = AnthropicAccountIdentity & {
|
|
9
|
+
score: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer'])
|
|
13
|
+
const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub']
|
|
14
|
+
|
|
15
|
+
export function normalizeAnthropicAccountIdentity(
|
|
16
|
+
identity: AnthropicAccountIdentity | null | undefined,
|
|
17
|
+
) {
|
|
18
|
+
const email =
|
|
19
|
+
typeof identity?.email === 'string' && identity.email.trim()
|
|
20
|
+
? identity.email.trim().toLowerCase()
|
|
21
|
+
: undefined
|
|
22
|
+
const accountId =
|
|
23
|
+
typeof identity?.accountId === 'string' && identity.accountId.trim()
|
|
24
|
+
? identity.accountId.trim()
|
|
25
|
+
: undefined
|
|
26
|
+
if (!email && !accountId) return undefined
|
|
27
|
+
return {
|
|
28
|
+
...(email ? { email } : {}),
|
|
29
|
+
...(accountId ? { accountId } : {}),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getCandidateFromRecord(record: Record<string, unknown>, path: string[]) {
|
|
34
|
+
const email = typeof record.email === 'string' ? record.email : undefined
|
|
35
|
+
const accountId = idKeys
|
|
36
|
+
.map((key) => {
|
|
37
|
+
const value = record[key]
|
|
38
|
+
return typeof value === 'string' ? value : undefined
|
|
39
|
+
})
|
|
40
|
+
.find((value) => {
|
|
41
|
+
return Boolean(value)
|
|
42
|
+
})
|
|
43
|
+
const normalized = normalizeAnthropicAccountIdentity({ email, accountId })
|
|
44
|
+
if (!normalized) return undefined
|
|
45
|
+
const hasIdentityHint = path.some((segment) => {
|
|
46
|
+
return identityHintKeys.has(segment)
|
|
47
|
+
})
|
|
48
|
+
return {
|
|
49
|
+
...normalized,
|
|
50
|
+
score: (normalized.email ? 4 : 0) + (normalized.accountId ? 2 : 0) + (hasIdentityHint ? 2 : 0),
|
|
51
|
+
} satisfies IdentityCandidate
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectIdentityCandidates(value: unknown, path: string[] = []): IdentityCandidate[] {
|
|
55
|
+
if (!value || typeof value !== 'object') return []
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
return value.flatMap((entry) => {
|
|
58
|
+
return collectIdentityCandidates(entry, path)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const record = value as Record<string, unknown>
|
|
63
|
+
const nested = Object.entries(record).flatMap(([key, entry]) => {
|
|
64
|
+
return collectIdentityCandidates(entry, [...path, key])
|
|
65
|
+
})
|
|
66
|
+
const current = getCandidateFromRecord(record, path)
|
|
67
|
+
return current ? [current, ...nested] : nested
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractAnthropicAccountIdentity(value: unknown) {
|
|
71
|
+
const candidates = collectIdentityCandidates(value)
|
|
72
|
+
const best = candidates.sort((a, b) => {
|
|
73
|
+
return b.score - a.score
|
|
74
|
+
})[0]
|
|
75
|
+
if (!best) return undefined
|
|
76
|
+
return normalizeAnthropicAccountIdentity(best)
|
|
77
|
+
}
|
|
@@ -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
|
})()
|
|
@@ -682,6 +710,19 @@ function wrapResponseStream(response: Response, reverseToolNameMap: Map<string,
|
|
|
682
710
|
})
|
|
683
711
|
}
|
|
684
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
|
+
|
|
685
726
|
// --- Beta headers ---
|
|
686
727
|
|
|
687
728
|
function getRequiredBetas(modelId: string | undefined) {
|
|
@@ -737,7 +778,18 @@ async function getFreshOAuth(
|
|
|
737
778
|
await setAnthropicAuth(refreshed, client)
|
|
738
779
|
const store = await loadAccountStore()
|
|
739
780
|
if (store.accounts.length > 0) {
|
|
740
|
-
|
|
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 })
|
|
741
793
|
await saveAccountStore(store)
|
|
742
794
|
}
|
|
743
795
|
return refreshed
|
|
@@ -752,6 +804,12 @@ async function getFreshOAuth(
|
|
|
752
804
|
|
|
753
805
|
const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
754
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
|
+
},
|
|
755
813
|
auth: {
|
|
756
814
|
provider: 'anthropic',
|
|
757
815
|
async loader(
|
|
@@ -788,21 +846,27 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
788
846
|
.catch(() => undefined)
|
|
789
847
|
: undefined
|
|
790
848
|
|
|
791
|
-
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
792
|
-
client.tui.showToast({
|
|
793
|
-
body: { message: msg, variant: 'error' },
|
|
794
|
-
}).catch(() => {})
|
|
795
|
-
})
|
|
796
849
|
const headers = new Headers(init?.headers)
|
|
797
850
|
if (input instanceof Request) {
|
|
798
851
|
input.headers.forEach((v, k) => {
|
|
799
852
|
if (!headers.has(k)) headers.set(k, v)
|
|
800
853
|
})
|
|
801
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
|
+
})
|
|
802
865
|
const betas = getRequiredBetas(rewritten.modelId)
|
|
803
866
|
|
|
804
867
|
const runRequest = async (auth: OAuthStored) => {
|
|
805
868
|
const requestHeaders = new Headers(headers)
|
|
869
|
+
requestHeaders.delete(TOAST_SESSION_HEADER)
|
|
806
870
|
requestHeaders.set('accept', 'application/json')
|
|
807
871
|
requestHeaders.set(
|
|
808
872
|
'anthropic-beta',
|
|
@@ -839,7 +903,10 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
839
903
|
// Show toast notification so Discord thread shows the rotation
|
|
840
904
|
client.tui.showToast({
|
|
841
905
|
body: {
|
|
842
|
-
message:
|
|
906
|
+
message: appendToastSessionMarker({
|
|
907
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
908
|
+
sessionId,
|
|
909
|
+
}),
|
|
843
910
|
variant: 'info',
|
|
844
911
|
},
|
|
845
912
|
|
|
@@ -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
|
|
|
@@ -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)
|
|
@@ -224,7 +224,7 @@ describe('system-message', () => {
|
|
|
224
224
|
|
|
225
225
|
This creates a new Discord thread with an isolated git worktree and starts a session in it. The worktree name should be kebab-case and descriptive of the task.
|
|
226
226
|
|
|
227
|
-
By default, worktrees are created from \`
|
|
227
|
+
By default, worktrees are created from \`HEAD\`, which means whatever commit or branch the current checkout is on. If you want a different base, pass \`--base-branch\` or use the slash command option explicitly.
|
|
228
228
|
|
|
229
229
|
Critical recursion guard:
|
|
230
230
|
- If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
|