@otto-assistant/bridge 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-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/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 +1 -1
- package/src/anthropic-account-identity.test.ts +52 -0
- package/src/anthropic-account-identity.ts +77 -0
- package/src/anthropic-auth-plugin.ts +80 -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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Helpers for extracting and normalizing Anthropic OAuth account identity.
|
|
2
|
+
const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer']);
|
|
3
|
+
const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub'];
|
|
4
|
+
export function normalizeAnthropicAccountIdentity(identity) {
|
|
5
|
+
const email = typeof identity?.email === 'string' && identity.email.trim()
|
|
6
|
+
? identity.email.trim().toLowerCase()
|
|
7
|
+
: undefined;
|
|
8
|
+
const accountId = typeof identity?.accountId === 'string' && identity.accountId.trim()
|
|
9
|
+
? identity.accountId.trim()
|
|
10
|
+
: undefined;
|
|
11
|
+
if (!email && !accountId)
|
|
12
|
+
return undefined;
|
|
13
|
+
return {
|
|
14
|
+
...(email ? { email } : {}),
|
|
15
|
+
...(accountId ? { accountId } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function getCandidateFromRecord(record, path) {
|
|
19
|
+
const email = typeof record.email === 'string' ? record.email : undefined;
|
|
20
|
+
const accountId = idKeys
|
|
21
|
+
.map((key) => {
|
|
22
|
+
const value = record[key];
|
|
23
|
+
return typeof value === 'string' ? value : undefined;
|
|
24
|
+
})
|
|
25
|
+
.find((value) => {
|
|
26
|
+
return Boolean(value);
|
|
27
|
+
});
|
|
28
|
+
const normalized = normalizeAnthropicAccountIdentity({ email, accountId });
|
|
29
|
+
if (!normalized)
|
|
30
|
+
return undefined;
|
|
31
|
+
const hasIdentityHint = path.some((segment) => {
|
|
32
|
+
return identityHintKeys.has(segment);
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
...normalized,
|
|
36
|
+
score: (normalized.email ? 4 : 0) + (normalized.accountId ? 2 : 0) + (hasIdentityHint ? 2 : 0),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function collectIdentityCandidates(value, path = []) {
|
|
40
|
+
if (!value || typeof value !== 'object')
|
|
41
|
+
return [];
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
return value.flatMap((entry) => {
|
|
44
|
+
return collectIdentityCandidates(entry, path);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const record = value;
|
|
48
|
+
const nested = Object.entries(record).flatMap(([key, entry]) => {
|
|
49
|
+
return collectIdentityCandidates(entry, [...path, key]);
|
|
50
|
+
});
|
|
51
|
+
const current = getCandidateFromRecord(record, path);
|
|
52
|
+
return current ? [current, ...nested] : nested;
|
|
53
|
+
}
|
|
54
|
+
export function extractAnthropicAccountIdentity(value) {
|
|
55
|
+
const candidates = collectIdentityCandidates(value);
|
|
56
|
+
const best = candidates.sort((a, b) => {
|
|
57
|
+
return b.score - a.score;
|
|
58
|
+
})[0];
|
|
59
|
+
if (!best)
|
|
60
|
+
return undefined;
|
|
61
|
+
return normalizeAnthropicAccountIdentity(best);
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Tests Anthropic OAuth account identity parsing and normalization.
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { extractAnthropicAccountIdentity, normalizeAnthropicAccountIdentity, } from './anthropic-account-identity.js';
|
|
4
|
+
describe('normalizeAnthropicAccountIdentity', () => {
|
|
5
|
+
test('normalizes email casing and drops empty values', () => {
|
|
6
|
+
expect(normalizeAnthropicAccountIdentity({
|
|
7
|
+
email: ' User@Example.com ',
|
|
8
|
+
accountId: ' user_123 ',
|
|
9
|
+
})).toEqual({
|
|
10
|
+
email: 'user@example.com',
|
|
11
|
+
accountId: 'user_123',
|
|
12
|
+
});
|
|
13
|
+
expect(normalizeAnthropicAccountIdentity({ email: ' ' })).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('extractAnthropicAccountIdentity', () => {
|
|
17
|
+
test('prefers nested user profile identity from client_data responses', () => {
|
|
18
|
+
expect(extractAnthropicAccountIdentity({
|
|
19
|
+
organizations: [{ id: 'org_123', name: 'Workspace' }],
|
|
20
|
+
user: {
|
|
21
|
+
id: 'usr_123',
|
|
22
|
+
email: 'User@Example.com',
|
|
23
|
+
},
|
|
24
|
+
})).toEqual({
|
|
25
|
+
accountId: 'usr_123',
|
|
26
|
+
email: 'user@example.com',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
test('falls back to profile-style payloads without email', () => {
|
|
30
|
+
expect(extractAnthropicAccountIdentity({
|
|
31
|
+
profile: {
|
|
32
|
+
user_id: 'usr_456',
|
|
33
|
+
},
|
|
34
|
+
})).toEqual({
|
|
35
|
+
accountId: 'usr_456',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
|
|
24
24
|
*/
|
|
25
25
|
import { loadAccountStore, rememberAnthropicOAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from './anthropic-auth-state.js';
|
|
26
|
+
import { extractAnthropicAccountIdentity, } from './anthropic-account-identity.js';
|
|
26
27
|
// PKCE (Proof Key for Code Exchange) using Web Crypto API.
|
|
27
28
|
// Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
|
|
28
29
|
function base64urlEncode(bytes) {
|
|
@@ -52,6 +53,8 @@ const CLIENT_ID = (() => {
|
|
|
52
53
|
})();
|
|
53
54
|
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
54
55
|
const CREATE_API_KEY_URL = 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key';
|
|
56
|
+
const CLIENT_DATA_URL = 'https://api.anthropic.com/api/oauth/claude_cli/client_data';
|
|
57
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
55
58
|
const CALLBACK_PORT = 53692;
|
|
56
59
|
const CALLBACK_PATH = '/callback';
|
|
57
60
|
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
@@ -64,6 +67,7 @@ const CLAUDE_CODE_BETA = 'claude-code-20250219';
|
|
|
64
67
|
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
65
68
|
const FINE_GRAINED_TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14';
|
|
66
69
|
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14';
|
|
70
|
+
const TOAST_SESSION_HEADER = 'x-kimaki-session-id';
|
|
67
71
|
const ANTHROPIC_HOSTS = new Set([
|
|
68
72
|
'api.anthropic.com',
|
|
69
73
|
'claude.ai',
|
|
@@ -227,6 +231,29 @@ async function createApiKey(accessToken) {
|
|
|
227
231
|
const json = JSON.parse(responseText);
|
|
228
232
|
return { type: 'success', key: json.raw_key };
|
|
229
233
|
}
|
|
234
|
+
async function fetchAnthropicAccountIdentity(accessToken) {
|
|
235
|
+
const urls = [CLIENT_DATA_URL, PROFILE_URL];
|
|
236
|
+
for (const url of urls) {
|
|
237
|
+
const responseText = await requestText(url, {
|
|
238
|
+
method: 'GET',
|
|
239
|
+
headers: {
|
|
240
|
+
Accept: 'application/json',
|
|
241
|
+
authorization: `Bearer ${accessToken}`,
|
|
242
|
+
'user-agent': process.env.OPENCODE_ANTHROPIC_USER_AGENT || `claude-cli/${CLAUDE_CODE_VERSION}`,
|
|
243
|
+
'x-app': 'cli',
|
|
244
|
+
},
|
|
245
|
+
}).catch(() => {
|
|
246
|
+
return undefined;
|
|
247
|
+
});
|
|
248
|
+
if (!responseText)
|
|
249
|
+
continue;
|
|
250
|
+
const parsed = JSON.parse(responseText);
|
|
251
|
+
const identity = extractAnthropicAccountIdentity(parsed);
|
|
252
|
+
if (identity)
|
|
253
|
+
return identity;
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
230
257
|
async function startCallbackServer(expectedState) {
|
|
231
258
|
return new Promise((resolve, reject) => {
|
|
232
259
|
let settle;
|
|
@@ -376,12 +403,13 @@ function buildAuthorizeHandler(mode) {
|
|
|
376
403
|
if (mode === 'apikey') {
|
|
377
404
|
return createApiKey(creds.access);
|
|
378
405
|
}
|
|
406
|
+
const identity = await fetchAnthropicAccountIdentity(creds.access);
|
|
379
407
|
await rememberAnthropicOAuth({
|
|
380
408
|
type: 'oauth',
|
|
381
409
|
refresh: creds.refresh,
|
|
382
410
|
access: creds.access,
|
|
383
411
|
expires: creds.expires,
|
|
384
|
-
});
|
|
412
|
+
}, identity);
|
|
385
413
|
return creds;
|
|
386
414
|
};
|
|
387
415
|
if (!isRemote) {
|
|
@@ -395,8 +423,7 @@ function buildAuthorizeHandler(mode) {
|
|
|
395
423
|
const result = await waitForCallback(auth.callbackServer);
|
|
396
424
|
return await finalize(result);
|
|
397
425
|
}
|
|
398
|
-
catch
|
|
399
|
-
console.error(`[anthropic-auth] ${error}`);
|
|
426
|
+
catch {
|
|
400
427
|
return { type: 'failed' };
|
|
401
428
|
}
|
|
402
429
|
})();
|
|
@@ -414,8 +441,7 @@ function buildAuthorizeHandler(mode) {
|
|
|
414
441
|
const result = await waitForCallback(auth.callbackServer, input);
|
|
415
442
|
return await finalize(result);
|
|
416
443
|
}
|
|
417
|
-
catch
|
|
418
|
-
console.error(`[anthropic-auth] ${error}`);
|
|
444
|
+
catch {
|
|
419
445
|
return { type: 'failed' };
|
|
420
446
|
}
|
|
421
447
|
})();
|
|
@@ -577,6 +603,12 @@ function wrapResponseStream(response, reverseToolNameMap) {
|
|
|
577
603
|
headers: response.headers,
|
|
578
604
|
});
|
|
579
605
|
}
|
|
606
|
+
function appendToastSessionMarker({ message, sessionId, }) {
|
|
607
|
+
if (!sessionId) {
|
|
608
|
+
return message;
|
|
609
|
+
}
|
|
610
|
+
return `${message} ${sessionId}`;
|
|
611
|
+
}
|
|
580
612
|
// --- Beta headers ---
|
|
581
613
|
function getRequiredBetas(modelId) {
|
|
582
614
|
const betas = [CLAUDE_CODE_BETA, OAUTH_BETA, FINE_GRAINED_TOOL_STREAMING_BETA];
|
|
@@ -624,7 +656,19 @@ async function getFreshOAuth(getAuth, client) {
|
|
|
624
656
|
await setAnthropicAuth(refreshed, client);
|
|
625
657
|
const store = await loadAccountStore();
|
|
626
658
|
if (store.accounts.length > 0) {
|
|
627
|
-
|
|
659
|
+
const identity = (() => {
|
|
660
|
+
const currentIndex = store.accounts.findIndex((account) => {
|
|
661
|
+
return account.refresh === latest.refresh || account.access === latest.access;
|
|
662
|
+
});
|
|
663
|
+
const current = currentIndex >= 0 ? store.accounts[currentIndex] : undefined;
|
|
664
|
+
if (!current)
|
|
665
|
+
return undefined;
|
|
666
|
+
return {
|
|
667
|
+
...(current.email ? { email: current.email } : {}),
|
|
668
|
+
...(current.accountId ? { accountId: current.accountId } : {}),
|
|
669
|
+
};
|
|
670
|
+
})();
|
|
671
|
+
upsertAccount(store, { ...refreshed, ...identity });
|
|
628
672
|
await saveAccountStore(store);
|
|
629
673
|
}
|
|
630
674
|
return refreshed;
|
|
@@ -637,6 +681,12 @@ async function getFreshOAuth(getAuth, client) {
|
|
|
637
681
|
// --- Plugin export ---
|
|
638
682
|
const AnthropicAuthPlugin = async ({ client }) => {
|
|
639
683
|
return {
|
|
684
|
+
'chat.headers': async (input, output) => {
|
|
685
|
+
if (input.model.providerID !== 'anthropic') {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
output.headers[TOAST_SESSION_HEADER] = input.sessionID;
|
|
689
|
+
},
|
|
640
690
|
auth: {
|
|
641
691
|
provider: 'anthropic',
|
|
642
692
|
async loader(getAuth, provider) {
|
|
@@ -668,11 +718,6 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
668
718
|
.text()
|
|
669
719
|
.catch(() => undefined)
|
|
670
720
|
: undefined;
|
|
671
|
-
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
672
|
-
client.tui.showToast({
|
|
673
|
-
body: { message: msg, variant: 'error' },
|
|
674
|
-
}).catch(() => { });
|
|
675
|
-
});
|
|
676
721
|
const headers = new Headers(init?.headers);
|
|
677
722
|
if (input instanceof Request) {
|
|
678
723
|
input.headers.forEach((v, k) => {
|
|
@@ -680,9 +725,19 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
680
725
|
headers.set(k, v);
|
|
681
726
|
});
|
|
682
727
|
}
|
|
728
|
+
const sessionId = headers.get(TOAST_SESSION_HEADER) ?? undefined;
|
|
729
|
+
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
730
|
+
client.tui.showToast({
|
|
731
|
+
body: {
|
|
732
|
+
message: appendToastSessionMarker({ message: msg, sessionId }),
|
|
733
|
+
variant: 'error',
|
|
734
|
+
},
|
|
735
|
+
}).catch(() => { });
|
|
736
|
+
});
|
|
683
737
|
const betas = getRequiredBetas(rewritten.modelId);
|
|
684
738
|
const runRequest = async (auth) => {
|
|
685
739
|
const requestHeaders = new Headers(headers);
|
|
740
|
+
requestHeaders.delete(TOAST_SESSION_HEADER);
|
|
686
741
|
requestHeaders.set('accept', 'application/json');
|
|
687
742
|
requestHeaders.set('anthropic-beta', mergeBetas(requestHeaders.get('anthropic-beta'), betas));
|
|
688
743
|
requestHeaders.set('anthropic-dangerous-direct-browser-access', 'true');
|
|
@@ -711,7 +766,10 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
711
766
|
// Show toast notification so Discord thread shows the rotation
|
|
712
767
|
client.tui.showToast({
|
|
713
768
|
body: {
|
|
714
|
-
message:
|
|
769
|
+
message: appendToastSessionMarker({
|
|
770
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
771
|
+
sessionId,
|
|
772
|
+
}),
|
|
715
773
|
variant: 'info',
|
|
716
774
|
},
|
|
717
775
|
}).catch(() => { });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { normalizeAnthropicAccountIdentity, } from './anthropic-account-identity.js';
|
|
4
5
|
const AUTH_LOCK_STALE_MS = 30_000;
|
|
5
6
|
const AUTH_LOCK_RETRY_MS = 100;
|
|
6
7
|
async function readJson(filePath, fallback) {
|
|
@@ -80,6 +81,8 @@ export function normalizeAccountStore(input) {
|
|
|
80
81
|
typeof account.refresh === 'string' &&
|
|
81
82
|
typeof account.access === 'string' &&
|
|
82
83
|
typeof account.expires === 'number' &&
|
|
84
|
+
(typeof account.email === 'undefined' || typeof account.email === 'string') &&
|
|
85
|
+
(typeof account.accountId === 'undefined' || typeof account.accountId === 'string') &&
|
|
83
86
|
typeof account.addedAt === 'number' &&
|
|
84
87
|
typeof account.lastUsed === 'number')
|
|
85
88
|
: [];
|
|
@@ -96,8 +99,13 @@ export async function saveAccountStore(store) {
|
|
|
96
99
|
}
|
|
97
100
|
/** Short label for an account: first 8 + last 4 chars of refresh token. */
|
|
98
101
|
export function accountLabel(account, index) {
|
|
102
|
+
const accountWithIdentity = account;
|
|
103
|
+
const identity = accountWithIdentity.email || accountWithIdentity.accountId;
|
|
99
104
|
const r = account.refresh;
|
|
100
105
|
const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r;
|
|
106
|
+
if (identity) {
|
|
107
|
+
return index !== undefined ? `#${index + 1} (${identity})` : identity;
|
|
108
|
+
}
|
|
101
109
|
return index !== undefined ? `#${index + 1} (${short})` : short;
|
|
102
110
|
}
|
|
103
111
|
function findCurrentAccountIndex(store, auth) {
|
|
@@ -116,14 +124,29 @@ function findCurrentAccountIndex(store, auth) {
|
|
|
116
124
|
return store.activeIndex;
|
|
117
125
|
}
|
|
118
126
|
export function upsertAccount(store, auth, now = Date.now()) {
|
|
127
|
+
const authWithIdentity = auth;
|
|
128
|
+
const identity = normalizeAnthropicAccountIdentity({
|
|
129
|
+
email: authWithIdentity.email,
|
|
130
|
+
accountId: authWithIdentity.accountId,
|
|
131
|
+
});
|
|
119
132
|
const index = store.accounts.findIndex((account) => {
|
|
120
|
-
|
|
133
|
+
if (account.refresh === auth.refresh || account.access === auth.access) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (identity?.accountId && account.accountId === identity.accountId) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (identity?.email && account.email === identity.email) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
121
143
|
});
|
|
122
144
|
const nextAccount = {
|
|
123
145
|
type: 'oauth',
|
|
124
146
|
refresh: auth.refresh,
|
|
125
147
|
access: auth.access,
|
|
126
148
|
expires: auth.expires,
|
|
149
|
+
...identity,
|
|
127
150
|
addedAt: now,
|
|
128
151
|
lastUsed: now,
|
|
129
152
|
};
|
|
@@ -139,14 +162,16 @@ export function upsertAccount(store, auth, now = Date.now()) {
|
|
|
139
162
|
...existing,
|
|
140
163
|
...nextAccount,
|
|
141
164
|
addedAt: existing.addedAt,
|
|
165
|
+
email: nextAccount.email || existing.email,
|
|
166
|
+
accountId: nextAccount.accountId || existing.accountId,
|
|
142
167
|
};
|
|
143
168
|
store.activeIndex = index;
|
|
144
169
|
return index;
|
|
145
170
|
}
|
|
146
|
-
export async function rememberAnthropicOAuth(auth) {
|
|
171
|
+
export async function rememberAnthropicOAuth(auth, identity) {
|
|
147
172
|
await withAuthStateLock(async () => {
|
|
148
173
|
const store = await loadAccountStore();
|
|
149
|
-
upsertAccount(store, auth);
|
|
174
|
+
upsertAccount(store, { ...auth, ...normalizeAnthropicAccountIdentity(identity) });
|
|
150
175
|
await saveAccountStore(store);
|
|
151
176
|
});
|
|
152
177
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Tests
|
|
1
|
+
// Tests Anthropic OAuth account persistence, deduplication, and rotation.
|
|
2
2
|
import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
6
|
-
import { authFilePath, loadAccountStore, rememberAnthropicOAuth, removeAccount, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
|
|
6
|
+
import { accountLabel, authFilePath, loadAccountStore, rememberAnthropicOAuth, removeAccount, rotateAnthropicAccount, saveAccountStore, shouldRotateAuth, } from './anthropic-auth-state.js';
|
|
7
7
|
const firstAccount = {
|
|
8
8
|
type: 'oauth',
|
|
9
9
|
refresh: 'refresh-first',
|
|
@@ -45,6 +45,25 @@ describe('rememberAnthropicOAuth', () => {
|
|
|
45
45
|
expires: 3,
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
test('deduplicates new tokens by email or account ID', async () => {
|
|
49
|
+
await rememberAnthropicOAuth(firstAccount, {
|
|
50
|
+
email: 'user@example.com',
|
|
51
|
+
accountId: 'usr_123',
|
|
52
|
+
});
|
|
53
|
+
await rememberAnthropicOAuth(secondAccount, {
|
|
54
|
+
email: 'User@example.com',
|
|
55
|
+
accountId: 'usr_123',
|
|
56
|
+
});
|
|
57
|
+
const store = await loadAccountStore();
|
|
58
|
+
expect(store.accounts).toHaveLength(1);
|
|
59
|
+
expect(store.accounts[0]).toMatchObject({
|
|
60
|
+
refresh: 'refresh-second',
|
|
61
|
+
access: 'access-second',
|
|
62
|
+
email: 'user@example.com',
|
|
63
|
+
accountId: 'usr_123',
|
|
64
|
+
});
|
|
65
|
+
expect(accountLabel(store.accounts[0])).toBe('user@example.com');
|
|
66
|
+
});
|
|
48
67
|
});
|
|
49
68
|
describe('rotateAnthropicAccount', () => {
|
|
50
69
|
test('rotates to the next stored account and syncs auth state', async () => {
|
package/dist/cli-parsing.test.js
CHANGED
|
@@ -22,8 +22,8 @@ function createCliForIdParsing() {
|
|
|
22
22
|
.command('add-project', 'Add a project')
|
|
23
23
|
.option('-g, --guild <guildId>', 'Discord guild/server ID');
|
|
24
24
|
cli.command('task delete <id>', 'Delete task');
|
|
25
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
26
|
-
cli.command('anthropic-accounts remove <
|
|
25
|
+
cli.command('anthropic-accounts list', 'List stored Anthropic accounts');
|
|
26
|
+
cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account');
|
|
27
27
|
return cli;
|
|
28
28
|
}
|
|
29
29
|
describe('goke CLI ID parsing', () => {
|
|
@@ -113,13 +113,16 @@ describe('goke CLI ID parsing', () => {
|
|
|
113
113
|
expect(result.args[0]).toBe(taskId);
|
|
114
114
|
expect(typeof result.args[0]).toBe('string');
|
|
115
115
|
});
|
|
116
|
-
test('
|
|
116
|
+
test('anthropic account remove parses index and email as strings', () => {
|
|
117
117
|
const cli = createCliForIdParsing();
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
expect(
|
|
118
|
+
const indexResult = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', '2'], { run: false });
|
|
119
|
+
const emailResult = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com'], { run: false });
|
|
120
|
+
expect(indexResult.args[0]).toBe('2');
|
|
121
|
+
expect(typeof indexResult.args[0]).toBe('string');
|
|
122
|
+
expect(emailResult.args[0]).toBe('user@example.com');
|
|
123
|
+
expect(typeof emailResult.args[0]).toBe('string');
|
|
121
124
|
});
|
|
122
|
-
test('
|
|
125
|
+
test('anthropic account commands are included in help output', () => {
|
|
123
126
|
const stdout = {
|
|
124
127
|
text: '',
|
|
125
128
|
write(data) {
|
|
@@ -128,10 +131,10 @@ describe('goke CLI ID parsing', () => {
|
|
|
128
131
|
};
|
|
129
132
|
const cli = goke('kimaki', { stdout: stdout });
|
|
130
133
|
cli.command('send', 'Send a message');
|
|
131
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
134
|
+
cli.command('anthropic-accounts list', 'List stored Anthropic accounts');
|
|
132
135
|
cli.help();
|
|
133
136
|
cli.parse(['node', 'kimaki', '--help'], { run: false });
|
|
134
137
|
expect(stdout.text).toContain('send');
|
|
135
|
-
expect(stdout.text).
|
|
138
|
+
expect(stdout.text).toContain('anthropic-accounts');
|
|
136
139
|
});
|
|
137
140
|
});
|
package/dist/cli.js
CHANGED
|
@@ -662,7 +662,8 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
|
|
|
662
662
|
options: [
|
|
663
663
|
{
|
|
664
664
|
value: 'gateway',
|
|
665
|
-
|
|
665
|
+
disabled: true,
|
|
666
|
+
label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
|
|
666
667
|
},
|
|
667
668
|
{
|
|
668
669
|
value: 'self_hosted',
|
|
@@ -2224,7 +2225,6 @@ cli
|
|
|
2224
2225
|
});
|
|
2225
2226
|
cli
|
|
2226
2227
|
.command('anthropic-accounts list', 'List stored Anthropic OAuth accounts used for automatic rotation')
|
|
2227
|
-
.hidden()
|
|
2228
2228
|
.action(async () => {
|
|
2229
2229
|
const store = await loadAccountStore();
|
|
2230
2230
|
console.log(`Store: ${accountsFilePath()}`);
|
|
@@ -2239,16 +2239,29 @@ cli
|
|
|
2239
2239
|
process.exit(0);
|
|
2240
2240
|
});
|
|
2241
2241
|
cli
|
|
2242
|
-
.command('anthropic-accounts remove <
|
|
2243
|
-
.
|
|
2244
|
-
|
|
2245
|
-
const
|
|
2246
|
-
|
|
2247
|
-
|
|
2242
|
+
.command('anthropic-accounts remove <indexOrEmail>', 'Remove a stored Anthropic OAuth account from the rotation pool by index or email')
|
|
2243
|
+
.action(async (indexOrEmail) => {
|
|
2244
|
+
const value = Number(indexOrEmail);
|
|
2245
|
+
const store = await loadAccountStore();
|
|
2246
|
+
const resolvedIndex = (() => {
|
|
2247
|
+
if (Number.isInteger(value) && value >= 1) {
|
|
2248
|
+
return value - 1;
|
|
2249
|
+
}
|
|
2250
|
+
const email = indexOrEmail.trim().toLowerCase();
|
|
2251
|
+
if (!email) {
|
|
2252
|
+
return -1;
|
|
2253
|
+
}
|
|
2254
|
+
return store.accounts.findIndex((account) => {
|
|
2255
|
+
return account.email?.toLowerCase() === email;
|
|
2256
|
+
});
|
|
2257
|
+
})();
|
|
2258
|
+
if (resolvedIndex < 0) {
|
|
2259
|
+
cliLogger.error('Usage: kimaki anthropic-accounts remove <index-or-email>');
|
|
2248
2260
|
process.exit(EXIT_NO_RESTART);
|
|
2249
2261
|
}
|
|
2250
|
-
|
|
2251
|
-
|
|
2262
|
+
const removed = store.accounts[resolvedIndex];
|
|
2263
|
+
await removeAccount(resolvedIndex);
|
|
2264
|
+
cliLogger.log(`Removed Anthropic account ${removed ? accountLabel(removed, resolvedIndex) : indexOrEmail}`);
|
|
2252
2265
|
process.exit(0);
|
|
2253
2266
|
});
|
|
2254
2267
|
cli
|
|
@@ -110,7 +110,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
110
110
|
.toJSON(),
|
|
111
111
|
new SlashCommandBuilder()
|
|
112
112
|
.setName('new-worktree')
|
|
113
|
-
.setDescription(truncateCommandDescription('Create a git worktree branch from
|
|
113
|
+
.setDescription(truncateCommandDescription('Create a git worktree branch from HEAD by default. Optionally pick a base branch.'))
|
|
114
114
|
.addStringOption((option) => {
|
|
115
115
|
option
|
|
116
116
|
.setName('name')
|
|
@@ -121,7 +121,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
121
121
|
.addStringOption((option) => {
|
|
122
122
|
option
|
|
123
123
|
.setName('base-branch')
|
|
124
|
-
.setDescription(truncateCommandDescription('Branch to create the worktree from (default:
|
|
124
|
+
.setDescription(truncateCommandDescription('Branch to create the worktree from (default: HEAD)'))
|
|
125
125
|
.setRequired(false)
|
|
126
126
|
.setAutocomplete(true);
|
|
127
127
|
return option;
|
package/dist/system-message.js
CHANGED
|
@@ -450,7 +450,7 @@ kimaki send --channel ${channelId} --prompt "your task description" --worktree w
|
|
|
450
450
|
|
|
451
451
|
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.
|
|
452
452
|
|
|
453
|
-
By default, worktrees are created from \`
|
|
453
|
+
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.
|
|
454
454
|
|
|
455
455
|
Critical recursion guard:
|
|
456
456
|
- If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
|
|
@@ -217,7 +217,7 @@ describe('system-message', () => {
|
|
|
217
217
|
|
|
218
218
|
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.
|
|
219
219
|
|
|
220
|
-
By default, worktrees are created from \`
|
|
220
|
+
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.
|
|
221
221
|
|
|
222
222
|
Critical recursion guard:
|
|
223
223
|
- If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
|
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
|
@@ -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,9 +903,13 @@ 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
|
},
|
|
912
|
+
|
|
845
913
|
}).catch(() => {})
|
|
846
914
|
const retryAuth = await getFreshOAuth(getAuth, client)
|
|
847
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
|
|
|
@@ -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.
|
package/src/system-message.ts
CHANGED
|
@@ -559,7 +559,7 @@ kimaki send --channel ${channelId} --prompt "your task description" --worktree w
|
|
|
559
559
|
|
|
560
560
|
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.
|
|
561
561
|
|
|
562
|
-
By default, worktrees are created from \`
|
|
562
|
+
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.
|
|
563
563
|
|
|
564
564
|
Critical recursion guard:
|
|
565
565
|
- If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
|
package/src/worktrees.test.ts
CHANGED
package/src/worktrees.ts
CHANGED
|
@@ -527,52 +527,6 @@ type WorktreeResult = {
|
|
|
527
527
|
async function resolveDefaultWorktreeTarget(
|
|
528
528
|
directory: string,
|
|
529
529
|
): Promise<string> {
|
|
530
|
-
const remoteHead = await execAsync(
|
|
531
|
-
'git symbolic-ref refs/remotes/origin/HEAD',
|
|
532
|
-
{
|
|
533
|
-
cwd: directory,
|
|
534
|
-
},
|
|
535
|
-
).catch(() => {
|
|
536
|
-
return null
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
const remoteRef = remoteHead?.stdout.trim()
|
|
540
|
-
if (remoteRef?.startsWith('refs/remotes/')) {
|
|
541
|
-
return remoteRef.replace('refs/remotes/', '')
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
const hasMain = await execAsync(
|
|
545
|
-
'git show-ref --verify --quiet refs/heads/main',
|
|
546
|
-
{
|
|
547
|
-
cwd: directory,
|
|
548
|
-
},
|
|
549
|
-
)
|
|
550
|
-
.then(() => {
|
|
551
|
-
return true
|
|
552
|
-
})
|
|
553
|
-
.catch(() => {
|
|
554
|
-
return false
|
|
555
|
-
})
|
|
556
|
-
if (hasMain) {
|
|
557
|
-
return 'main'
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const hasMaster = await execAsync(
|
|
561
|
-
'git show-ref --verify --quiet refs/heads/master',
|
|
562
|
-
{
|
|
563
|
-
cwd: directory,
|
|
564
|
-
},
|
|
565
|
-
)
|
|
566
|
-
.then(() => {
|
|
567
|
-
return true
|
|
568
|
-
})
|
|
569
|
-
.catch(() => {
|
|
570
|
-
return false
|
|
571
|
-
})
|
|
572
|
-
if (hasMaster) {
|
|
573
|
-
return 'master'
|
|
574
|
-
}
|
|
575
|
-
|
|
576
530
|
return 'HEAD'
|
|
577
531
|
}
|
|
578
532
|
|
|
@@ -608,7 +562,7 @@ export async function createWorktreeWithSubmodules({
|
|
|
608
562
|
}: {
|
|
609
563
|
directory: string
|
|
610
564
|
name: string
|
|
611
|
-
/** Override the base branch to create the worktree from. Defaults to
|
|
565
|
+
/** Override the base branch to create the worktree from. Defaults to HEAD. */
|
|
612
566
|
baseBranch?: string
|
|
613
567
|
/** Called with a short phase label so callers can update UI (e.g. Discord status message). */
|
|
614
568
|
onProgress?: (phase: string) => void
|