@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.
@@ -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 (error) {
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 (error) {
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
- upsertAccount(store, refreshed);
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: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
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
- return account.refresh === auth.refresh || account.access === auth.access;
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 for Anthropic OAuth multi-account persistence and rotation.
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 () => {
@@ -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').hidden();
26
- cli.command('anthropic-accounts remove <index>', 'Remove stored Anthropic account').hidden();
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('hidden anthropic account commands still parse', () => {
116
+ test('anthropic account remove parses index and email as strings', () => {
117
117
  const cli = createCliForIdParsing();
118
- const result = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', '2'], { run: false });
119
- expect(result.args[0]).toBe('2');
120
- expect(typeof result.args[0]).toBe('string');
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('hidden anthropic account commands are excluded from help output', () => {
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').hidden();
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).not.toContain('anthropic-accounts');
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
- label: 'Gateway (pre-built Kimaki bot — no setup needed)',
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 <index>', 'Remove a stored Anthropic OAuth account from the rotation pool')
2243
- .hidden()
2244
- .action(async (index) => {
2245
- const value = Number(index);
2246
- if (!Number.isInteger(value) || value < 1) {
2247
- cliLogger.error('Usage: kimaki anthropic-accounts remove <index>');
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
- await removeAccount(value - 1);
2251
- cliLogger.log(`Removed Anthropic account ${value}`);
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 origin/HEAD (or main). Optionally pick a base branch.'))
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: origin/HEAD or main)'))
124
+ .setDescription(truncateCommandDescription('Branch to create the worktree from (default: HEAD)'))
125
125
  .setRequired(false)
126
126
  .setAutocomplete(true);
127
127
  return option;
@@ -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 \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
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 \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
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
@@ -2,7 +2,7 @@
2
2
  "name": "@otto-assistant/bridge",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.96",
5
+ "version": "0.4.97",
6
6
  "scripts": {
7
7
  "dev": "tsx src/bin.ts",
8
8
  "prepublishOnly": "pnpm generate && pnpm tsc",
@@ -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 (error) {
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 (error) {
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
- upsertAccount(store, refreshed)
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: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
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 for Anthropic OAuth multi-account persistence and rotation.
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
- return account.refresh === auth.refresh || account.access === auth.access
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(auth: OAuthStored) {
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
  }
@@ -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').hidden()
31
- cli.command('anthropic-accounts remove <index>', 'Remove stored Anthropic account').hidden()
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('hidden anthropic account commands still parse', () => {
166
+ test('anthropic account remove parses index and email as strings', () => {
167
167
  const cli = createCliForIdParsing()
168
168
 
169
- const result = cli.parse(
169
+ const indexResult = cli.parse(
170
170
  ['node', 'kimaki', 'anthropic-accounts', 'remove', '2'],
171
171
  { run: false },
172
172
  )
173
173
 
174
- expect(result.args[0]).toBe('2')
175
- expect(typeof result.args[0]).toBe('string')
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('hidden anthropic account commands are excluded from help output', () => {
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').hidden()
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).not.toContain('anthropic-accounts')
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
- label: 'Gateway (pre-built Kimaki bot — no setup needed)',
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 <index>',
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
- .hidden()
3194
- .action(async (index: string) => {
3195
- const value = Number(index)
3196
- if (!Number.isInteger(value) || value < 1) {
3197
- cliLogger.error('Usage: kimaki anthropic-accounts remove <index>')
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
- await removeAccount(value - 1)
3202
- cliLogger.log(`Removed Anthropic account ${value}`)
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 origin/HEAD (or main). Optionally pick a base branch.'),
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: origin/HEAD or main)'),
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 \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
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.
@@ -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 \`origin/HEAD\` (the remote's default branch). To change the base branch for a project, the user can run \`git remote set-head origin <branch>\` in the project directory. For example, \`git remote set-head origin dev\` makes all new worktrees branch off \`origin/dev\` instead of \`origin/main\`.
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.
@@ -220,4 +220,5 @@ describe('worktrees', () => {
220
220
  fs.rmSync(sandbox, { recursive: true, force: true })
221
221
  }
222
222
  })
223
+
223
224
  })
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 origin/HEAD → main → master → HEAD. */
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