@karpeleslab/teamclaude 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # TeamClaude
2
+
3
+ Multi-account Claude proxy with automatic quota-based rotation for [Claude Code](https://claude.ai/claude-code).
4
+
5
+ Sits transparently between Claude Code and the Anthropic API, managing multiple Claude Max accounts and automatically switching when one approaches its session or weekly quota limit.
6
+
7
+ ![TeamClaude TUI](screenshots/teamclaude.png)
8
+
9
+ ## Features
10
+
11
+ - **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
12
+ - **Auto-retry on 429** — if an account is rate-limited, transparently retries with the next one
13
+ - **Interactive TUI** — real-time dashboard with quota bars, activity log, and keyboard controls
14
+ - **OAuth token refresh** — proactively refreshes expiring tokens and persists them to config
15
+ - **Request logging** — optional full request/response logging for debugging
16
+ - **Zero dependencies** — uses only Node.js built-in modules
17
+
18
+ ## Quick Start
19
+
20
+ Requires Node.js 18+.
21
+
22
+ ```bash
23
+ # Install
24
+ npm install -g @karpeleslab/teamclaude
25
+
26
+ # Import your current Claude Code credentials
27
+ teamclaude import
28
+
29
+ # Import a second account (log into it in Claude Code first, then import)
30
+ # claude /login
31
+ # teamclaude import
32
+
33
+ # Start the proxy
34
+ teamclaude server
35
+
36
+ # In another terminal, run Claude Code through the proxy
37
+ teamclaude run
38
+ ```
39
+
40
+ ## Adding Accounts
41
+
42
+ ### Import from Claude Code (recommended)
43
+
44
+ The easiest way to add accounts. Log into each account in Claude Code, then import:
45
+
46
+ ```bash
47
+ # Log into your first account in Claude Code
48
+ claude /login
49
+
50
+ # Import it
51
+ teamclaude import
52
+
53
+ # Switch to another account in Claude Code
54
+ claude /login
55
+
56
+ # Import that one too
57
+ teamclaude import
58
+ ```
59
+
60
+ Each import auto-detects the account email and subscription tier. Re-importing the same account updates its credentials.
61
+
62
+ ### API Key
63
+
64
+ For Anthropic API key accounts (billed via Console):
65
+
66
+ ```bash
67
+ teamclaude login --api
68
+ ```
69
+
70
+ ### OAuth Login (experimental)
71
+
72
+ Direct browser-based OAuth login without needing Claude Code:
73
+
74
+ ```bash
75
+ teamclaude login
76
+ ```
77
+
78
+ > **Note:** OAuth login is currently experimental. Tokens obtained this way may not work for proxying `/v1/messages` requests. Use `teamclaude import` as the reliable method.
79
+
80
+ ## Usage
81
+
82
+ ### Start the proxy server
83
+
84
+ ```bash
85
+ teamclaude server
86
+ ```
87
+
88
+ When running from a TTY, shows an interactive TUI with:
89
+ - Account table with session/weekly quota progress bars
90
+ - Real-time activity log with request tracking
91
+ - Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **q**uit
92
+
93
+ Falls back to plain log output when not a TTY (e.g. running as a service).
94
+
95
+ ### Run Claude Code through the proxy
96
+
97
+ ```bash
98
+ teamclaude run
99
+ ```
100
+
101
+ Or manually set the environment:
102
+
103
+ ```bash
104
+ eval $(teamclaude env)
105
+ claude
106
+ ```
107
+
108
+ ### Other commands
109
+
110
+ ```bash
111
+ teamclaude accounts # List accounts with live profile info
112
+ teamclaude status # Show live proxy status (requires running server)
113
+ teamclaude remove <name> # Remove an account
114
+ teamclaude api <path> # Call an API endpoint with account credentials
115
+ teamclaude help # Show all commands
116
+ ```
117
+
118
+ ### Request logging
119
+
120
+ Log full request/response details to a directory (one file per request):
121
+
122
+ ```bash
123
+ teamclaude server --log-to /tmp/requests
124
+ ```
125
+
126
+ ## Configuration
127
+
128
+ Config is stored at `~/.config/teamclaude.json` (or `$XDG_CONFIG_HOME/teamclaude.json`). A random proxy API key is generated on first use.
129
+
130
+ Override the config path with `TEAMCLAUDE_CONFIG`:
131
+
132
+ ```bash
133
+ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
134
+ ```
135
+
136
+ ### Config format
137
+
138
+ ```json
139
+ {
140
+ "proxy": {
141
+ "port": 3456,
142
+ "apiKey": "tc-auto-generated-key"
143
+ },
144
+ "upstream": "https://api.anthropic.com",
145
+ "switchThreshold": 0.98,
146
+ "accounts": [
147
+ {
148
+ "name": "user@example.com",
149
+ "type": "oauth",
150
+ "accountUuid": "...",
151
+ "accessToken": "sk-ant-oat01-...",
152
+ "refreshToken": "sk-ant-ort01-...",
153
+ "expiresAt": 1774384968427
154
+ }
155
+ ]
156
+ }
157
+ ```
158
+
159
+ ## How It Works
160
+
161
+ 1. Claude Code connects to the local proxy instead of `api.anthropic.com`
162
+ 2. The proxy selects the active account and forwards requests with that account's credentials
163
+ 3. Rate limit headers from the API (`anthropic-ratelimit-unified-*`) track session and weekly quota
164
+ 4. When usage reaches the threshold, the proxy switches to the next available account
165
+ 5. If all accounts are exhausted, returns 429 with the soonest reset time
166
+
167
+ ## License
168
+
169
+ MIT
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@karpeleslab/teamclaude",
3
+ "version": "1.0.0",
4
+ "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "teamclaude": "src/index.js"
9
+ },
10
+ "files": [
11
+ "src/"
12
+ ],
13
+ "scripts": {
14
+ "start": "node src/index.js"
15
+ },
16
+ "keywords": [
17
+ "claude",
18
+ "anthropic",
19
+ "proxy",
20
+ "load-balancer",
21
+ "multi-account"
22
+ ],
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ }
27
+ }
@@ -0,0 +1,323 @@
1
+ import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
2
+
3
+ function emptyQuota() {
4
+ return {
5
+ // Standard API rate limits (API key accounts)
6
+ tokensLimit: null,
7
+ tokensRemaining: null,
8
+ requestsLimit: null,
9
+ requestsRemaining: null,
10
+ // Unified rate limits (Claude Max accounts)
11
+ unified5h: null, // utilization 0-1
12
+ unified7d: null, // utilization 0-1
13
+ unified5hReset: null, // ms timestamp
14
+ unified7dReset: null, // ms timestamp
15
+ unifiedStatus: null, // allowed | allowed_warning | rejected
16
+ resetsAt: null,
17
+ };
18
+ }
19
+
20
+ export class AccountManager {
21
+ constructor(accounts, switchThreshold = 0.98) {
22
+ this.accounts = accounts.map((acct, index) => ({
23
+ index,
24
+ name: acct.name,
25
+ type: acct.type,
26
+ credential: acct.accessToken || acct.apiKey,
27
+ refreshToken: acct.refreshToken || null,
28
+ expiresAt: acct.expiresAt || null,
29
+ status: 'active',
30
+ quota: emptyQuota(),
31
+ usage: {
32
+ totalInputTokens: 0,
33
+ totalOutputTokens: 0,
34
+ totalRequests: 0,
35
+ lastUsed: null,
36
+ },
37
+ rateLimitedUntil: null,
38
+ }));
39
+ this.currentIndex = 0;
40
+ this.switchThreshold = switchThreshold;
41
+ }
42
+
43
+ /**
44
+ * Get the best available account, rotating if the current one is near quota.
45
+ * Returns null if all accounts are exhausted.
46
+ */
47
+ getActiveAccount() {
48
+ const current = this.accounts[this.currentIndex];
49
+ if (this._isAvailable(current)) {
50
+ return current;
51
+ }
52
+ return this._selectNext();
53
+ }
54
+
55
+ _isAvailable(account) {
56
+ if (!account) return false;
57
+
58
+ // Check rate limit expiry
59
+ if (account.status === 'throttled' && account.rateLimitedUntil) {
60
+ if (Date.now() < account.rateLimitedUntil) return false;
61
+ account.status = 'active';
62
+ account.rateLimitedUntil = null;
63
+ console.log(`[TeamClaude] Account "${account.name}" rate limit expired, marking active`);
64
+ }
65
+
66
+ if (account.status === 'exhausted' || account.status === 'error') return false;
67
+ if (this._isNearQuota(account)) return false;
68
+
69
+ return true;
70
+ }
71
+
72
+ _isNearQuota(account) {
73
+ const q = account.quota;
74
+ const now = Date.now();
75
+
76
+ // Clear expired unified quotas
77
+ if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
78
+ console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
79
+ q.unified5h = null;
80
+ q.unified5hReset = null;
81
+ }
82
+ if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
83
+ console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
84
+ q.unified7d = null;
85
+ q.unified7dReset = null;
86
+ q.unifiedStatus = null;
87
+ }
88
+
89
+ // Clear expired standard quotas
90
+ if (q.resetsAt && now >= new Date(q.resetsAt).getTime()) {
91
+ q.tokensRemaining = null;
92
+ q.tokensLimit = null;
93
+ q.requestsRemaining = null;
94
+ q.requestsLimit = null;
95
+ q.resetsAt = null;
96
+ }
97
+
98
+ // Unified quotas (Claude Max) — utilization is already 0-1
99
+ if (q.unified5h != null && q.unified5h >= this.switchThreshold) return true;
100
+ if (q.unified7d != null && q.unified7d >= this.switchThreshold) return true;
101
+
102
+ // Standard quotas (API key accounts)
103
+ if (q.tokensLimit != null && q.tokensRemaining != null) {
104
+ const used = 1 - (q.tokensRemaining / q.tokensLimit);
105
+ if (used >= this.switchThreshold) return true;
106
+ }
107
+
108
+ if (q.requestsLimit != null && q.requestsRemaining != null) {
109
+ const used = 1 - (q.requestsRemaining / q.requestsLimit);
110
+ if (used >= this.switchThreshold) return true;
111
+ }
112
+
113
+ return false;
114
+ }
115
+
116
+ _selectNext() {
117
+ const startIndex = this.currentIndex;
118
+
119
+ for (let i = 1; i <= this.accounts.length; i++) {
120
+ const idx = (startIndex + i) % this.accounts.length;
121
+ const account = this.accounts[idx];
122
+
123
+ if (this._isAvailable(account)) {
124
+ this.currentIndex = idx;
125
+ console.log(`[TeamClaude] Switched to account "${account.name}"`);
126
+ return account;
127
+ }
128
+ }
129
+
130
+ // All accounts unavailable — find the one that resets soonest
131
+ let soonestAccount = null;
132
+ let soonestTime = Infinity;
133
+
134
+ for (const account of this.accounts) {
135
+ const resetTime = account.rateLimitedUntil
136
+ || account.quota.unified5hReset
137
+ || account.quota.unified7dReset
138
+ || (account.quota.resetsAt ? new Date(account.quota.resetsAt).getTime() : null);
139
+
140
+ if (resetTime && resetTime < soonestTime) {
141
+ soonestTime = resetTime;
142
+ soonestAccount = account;
143
+ }
144
+ }
145
+
146
+ if (soonestAccount && soonestTime <= Date.now()) {
147
+ soonestAccount.status = 'active';
148
+ soonestAccount.rateLimitedUntil = null;
149
+ this.currentIndex = soonestAccount.index;
150
+ console.log(`[TeamClaude] Account "${soonestAccount.name}" reset, switching to it`);
151
+ return soonestAccount;
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Update an account's quota tracking from upstream response headers.
159
+ */
160
+ updateQuota(accountIndex, headers) {
161
+ const account = this.accounts[accountIndex];
162
+ if (!account) return;
163
+
164
+ // Unified rate limits (Claude Max)
165
+ const u5h = parseFloat(headers['anthropic-ratelimit-unified-5h-utilization']);
166
+ const u7d = parseFloat(headers['anthropic-ratelimit-unified-7d-utilization']);
167
+ if (!isNaN(u5h)) account.quota.unified5h = u5h;
168
+ if (!isNaN(u7d)) account.quota.unified7d = u7d;
169
+
170
+ const r5h = headers['anthropic-ratelimit-unified-5h-reset'];
171
+ const r7d = headers['anthropic-ratelimit-unified-7d-reset'];
172
+ if (r5h) account.quota.unified5hReset = parseInt(r5h, 10) * 1000;
173
+ if (r7d) account.quota.unified7dReset = parseInt(r7d, 10) * 1000;
174
+
175
+ const uStatus = headers['anthropic-ratelimit-unified-status'];
176
+ if (uStatus) account.quota.unifiedStatus = uStatus;
177
+
178
+ // Standard rate limits (API key accounts)
179
+ const tokensLimit = parseInt(headers['anthropic-ratelimit-tokens-limit'], 10);
180
+ const tokensRemaining = parseInt(headers['anthropic-ratelimit-tokens-remaining'], 10);
181
+ const tokensReset = headers['anthropic-ratelimit-tokens-reset'];
182
+ const requestsLimit = parseInt(headers['anthropic-ratelimit-requests-limit'], 10);
183
+ const requestsRemaining = parseInt(headers['anthropic-ratelimit-requests-remaining'], 10);
184
+ const requestsReset = headers['anthropic-ratelimit-requests-reset'];
185
+
186
+ if (!isNaN(tokensLimit)) account.quota.tokensLimit = tokensLimit;
187
+ if (!isNaN(tokensRemaining)) account.quota.tokensRemaining = tokensRemaining;
188
+ if (!isNaN(requestsLimit)) account.quota.requestsLimit = requestsLimit;
189
+ if (!isNaN(requestsRemaining)) account.quota.requestsRemaining = requestsRemaining;
190
+
191
+ if (tokensReset) account.quota.resetsAt = tokensReset;
192
+ else if (requestsReset) account.quota.resetsAt = requestsReset;
193
+
194
+ account.usage.totalRequests++;
195
+ account.usage.lastUsed = new Date().toISOString();
196
+
197
+ // Log when approaching quota
198
+ if (this._isNearQuota(account)) {
199
+ const pct = account.quota.unified7d != null
200
+ ? (account.quota.unified7d * 100).toFixed(1)
201
+ : account.quota.tokensLimit
202
+ ? ((1 - account.quota.tokensRemaining / account.quota.tokensLimit) * 100).toFixed(1)
203
+ : '?';
204
+ console.log(`[TeamClaude] Account "${account.name}" at ${pct}% usage — will switch on next request`);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Update cumulative token usage from response body data.
210
+ */
211
+ updateUsage(accountIndex, inputTokens, outputTokens) {
212
+ const account = this.accounts[accountIndex];
213
+ if (!account) return;
214
+ if (inputTokens) account.usage.totalInputTokens += inputTokens;
215
+ if (outputTokens) account.usage.totalOutputTokens += outputTokens;
216
+ }
217
+
218
+ /**
219
+ * Mark an account as rate-limited for a given duration.
220
+ */
221
+ markRateLimited(accountIndex, retryAfterSeconds) {
222
+ const account = this.accounts[accountIndex];
223
+ if (!account) return;
224
+ account.status = 'throttled';
225
+ account.rateLimitedUntil = Date.now() + (retryAfterSeconds * 1000);
226
+ console.log(`[TeamClaude] Account "${account.name}" rate limited for ${retryAfterSeconds}s`);
227
+ }
228
+
229
+ /**
230
+ * Ensure an OAuth account's token is fresh, refreshing if needed.
231
+ * Pass force=true to refresh regardless of expiry (e.g. after a 401).
232
+ * Concurrent calls for the same account coalesce into a single refresh.
233
+ */
234
+ async ensureTokenFresh(accountIndex, force = false) {
235
+ const account = this.accounts[accountIndex];
236
+ if (!account || account.type !== 'oauth' || !account.refreshToken) return;
237
+
238
+ if (!force && !isTokenExpiringSoon(account.expiresAt)) return;
239
+
240
+ // Coalesce concurrent refreshes
241
+ if (account._refreshPromise) return account._refreshPromise;
242
+
243
+ account._refreshPromise = (async () => {
244
+ console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
245
+ try {
246
+ const newTokens = await refreshAccessToken(account.refreshToken);
247
+ account.credential = newTokens.accessToken;
248
+ account.refreshToken = newTokens.refreshToken;
249
+ account.expiresAt = newTokens.expiresAt;
250
+ console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
251
+ this._onTokenRefresh?.(accountIndex, newTokens);
252
+ } catch (err) {
253
+ console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
254
+ account.status = 'error';
255
+ } finally {
256
+ account._refreshPromise = null;
257
+ }
258
+ })();
259
+
260
+ return account._refreshPromise;
261
+ }
262
+
263
+ /**
264
+ * Set a callback to persist refreshed tokens to config.
265
+ */
266
+ onTokenRefresh(callback) {
267
+ this._onTokenRefresh = callback;
268
+ }
269
+
270
+ /**
271
+ * Add a new account at runtime.
272
+ */
273
+ addAccount(acctData) {
274
+ const index = this.accounts.length;
275
+ this.accounts.push({
276
+ index,
277
+ name: acctData.name,
278
+ type: acctData.type,
279
+ credential: acctData.accessToken || acctData.apiKey,
280
+ refreshToken: acctData.refreshToken || null,
281
+ expiresAt: acctData.expiresAt || null,
282
+ status: 'active',
283
+ quota: emptyQuota(),
284
+ usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
285
+ rateLimitedUntil: null,
286
+ });
287
+ return index;
288
+ }
289
+
290
+ /**
291
+ * Remove an account by index.
292
+ */
293
+ removeAccount(index) {
294
+ if (index < 0 || index >= this.accounts.length) return;
295
+ this.accounts.splice(index, 1);
296
+ this.accounts.forEach((a, i) => a.index = i);
297
+ if (this.currentIndex >= this.accounts.length) {
298
+ this.currentIndex = Math.max(0, this.accounts.length - 1);
299
+ } else if (this.currentIndex > index) {
300
+ this.currentIndex--;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Return a status summary of all accounts (safe to expose, no credentials).
306
+ */
307
+ getStatus() {
308
+ return {
309
+ currentAccount: this.accounts[this.currentIndex]?.name,
310
+ switchThreshold: this.switchThreshold,
311
+ accounts: this.accounts.map(a => ({
312
+ name: a.name,
313
+ type: a.type,
314
+ status: a.status,
315
+ quota: { ...a.quota },
316
+ usage: { ...a.usage },
317
+ rateLimitedUntil: a.rateLimitedUntil
318
+ ? new Date(a.rateLimitedUntil).toISOString()
319
+ : null,
320
+ })),
321
+ };
322
+ }
323
+ }
package/src/config.js ADDED
@@ -0,0 +1,48 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+
6
+ export function getConfigPath() {
7
+ if (process.env.TEAMCLAUDE_CONFIG) return process.env.TEAMCLAUDE_CONFIG;
8
+ const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
9
+ return join(configDir, 'teamclaude.json');
10
+ }
11
+
12
+ export function createDefaultConfig() {
13
+ return {
14
+ proxy: {
15
+ port: 3456,
16
+ apiKey: 'tc-' + randomBytes(24).toString('base64url'),
17
+ },
18
+ upstream: 'https://api.anthropic.com',
19
+ switchThreshold: 0.98,
20
+ accounts: [],
21
+ };
22
+ }
23
+
24
+ export async function loadConfig() {
25
+ const path = getConfigPath();
26
+ try {
27
+ return JSON.parse(await readFile(path, 'utf-8'));
28
+ } catch (err) {
29
+ if (err.code === 'ENOENT') return null;
30
+ throw err;
31
+ }
32
+ }
33
+
34
+ export async function loadOrCreateConfig() {
35
+ let config = await loadConfig();
36
+ if (!config) {
37
+ config = createDefaultConfig();
38
+ await saveConfig(config);
39
+ console.log(`Created config at ${getConfigPath()}`);
40
+ }
41
+ return config;
42
+ }
43
+
44
+ export async function saveConfig(config) {
45
+ const path = getConfigPath();
46
+ await mkdir(dirname(path), { recursive: true });
47
+ await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
48
+ }