@rikcodes/teamclaude 1.1.19-rik.1 → 1.1.19-rik.3
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 +4 -4
- package/package.json +1 -1
- package/src/claude-env.js +46 -4
- package/src/index.js +11 -2
- package/src/provider.js +20 -0
- package/src/tui.js +33 -5
package/README.md
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
> Already have upstream installed globally? Run `npm uninstall -g @karpeleslab/teamclaude` first —
|
|
25
25
|
> both packages provide the `teamclaude` command.
|
|
26
26
|
>
|
|
27
|
-
> Branch: `rik/
|
|
27
|
+
> Branch: `rik/main`. Everything else matches upstream.
|
|
28
28
|
|
|
29
|
-
[](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml)
|
|
30
30
|
[](https://www.npmjs.com/package/@rikcodes/teamclaude)
|
|
31
31
|
[](https://nodejs.org)
|
|
32
32
|
[](LICENSE)
|
|
@@ -190,12 +190,12 @@ Versions are `<upstream base>-rik.<n>`, e.g. `1.1.19-rik.1`. The self-updater or
|
|
|
190
190
|
|
|
191
191
|
1. Rebase onto the upstream release you want as the base, if any.
|
|
192
192
|
2. Bump `version` in `package.json` and commit.
|
|
193
|
-
3. Push to `rik/
|
|
193
|
+
3. Push to `rik/main` — the Publish workflow runs the tests, publishes to npm, and cuts a GitHub release.
|
|
194
194
|
|
|
195
195
|
The workflow authenticates with npm Trusted Publishing (OIDC), which needs a one-time setup on npmjs.com: `@rikcodes/teamclaude` → Settings → Trusted Publisher → GitHub Actions, owner `rikbrown`, repo `teamclaude`, workflow `publish.yml`. Until that exists, publish by hand:
|
|
196
196
|
|
|
197
197
|
```bash
|
|
198
|
-
pnpm publish --publish-branch rik/
|
|
198
|
+
pnpm publish --publish-branch rik/main --tag latest --otp=<code>
|
|
199
199
|
```
|
|
200
200
|
|
|
201
201
|
A prerelease version always needs an explicit `--tag`, and `latest` is the tag the self-updater reads.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rikcodes/teamclaude",
|
|
3
|
-
"version": "1.1.19-rik.
|
|
3
|
+
"version": "1.1.19-rik.3",
|
|
4
4
|
"description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/claude-env.js
CHANGED
|
@@ -26,11 +26,50 @@ export function validPort(port) {
|
|
|
26
26
|
return n;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The loopback entries every launched client gets. They keep the client's own
|
|
30
|
+
// localhost traffic out of the proxy: a forward to loopback is refused
|
|
31
|
+
// (forward-target.js), so a client that proxied it would get a 403 instead of
|
|
32
|
+
// its own dev server.
|
|
33
|
+
export const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1'];
|
|
34
|
+
|
|
35
|
+
/** True if `value` names `*` — "proxy nothing" — among its entries. */
|
|
36
|
+
export function bypassesAllHosts(value) {
|
|
37
|
+
return String(value ?? '').split(',').some((entry) => entry.trim() === '*');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The NO_PROXY a launched client gets: ours, plus whatever the operator had.
|
|
42
|
+
*
|
|
43
|
+
* Replacing theirs broke the case the list exists for. A local dev host is
|
|
44
|
+
* rarely spelled `localhost` — `*.test` and friends resolve to 127.0.0.1
|
|
45
|
+
* through a local resolver — so with only our three entries the client proxies
|
|
46
|
+
* it, the proxy refuses the loopback forward, and the client retries the 403 on
|
|
47
|
+
* a loop. Their entries are kept verbatim (a leading dot or a `host:port` is
|
|
48
|
+
* theirs to mean), deduped case-insensitively, ours first.
|
|
49
|
+
*
|
|
50
|
+
* `*` is the one entry dropped: it routes every host around the proxy,
|
|
51
|
+
* api.anthropic.com included, which silently turns the launch into a direct run
|
|
52
|
+
* — no rotation, the operator's own quota. `--no-mitm` is how that is asked for.
|
|
53
|
+
*/
|
|
54
|
+
export function mergeNoProxy(...inherited) {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const out = [];
|
|
57
|
+
const entries = inherited.flatMap((value) => String(value ?? '').split(','));
|
|
58
|
+
for (const entry of [...LOOPBACK_NO_PROXY, ...entries]) {
|
|
59
|
+
const host = entry.trim();
|
|
60
|
+
if (!host || host === '*' || seen.has(host.toLowerCase())) continue;
|
|
61
|
+
seen.add(host.toLowerCase());
|
|
62
|
+
out.push(host);
|
|
63
|
+
}
|
|
64
|
+
return out.join(',');
|
|
65
|
+
}
|
|
66
|
+
|
|
29
67
|
// Build the shell `export` lines that point Claude Code — or any tool that
|
|
30
68
|
// spawns it, e.g. an agent multiplexer — at the proxy. This is the same
|
|
31
69
|
// environment `teamclaude run` sets up, but emitted for `eval "$(teamclaude
|
|
32
70
|
// env)"` instead of launching claude directly. Pure and side-effect free so it
|
|
33
|
-
// can be unit-tested; the caller resolves the port, cert path, and
|
|
71
|
+
// can be unit-tested; the caller resolves the port, cert path, holdSeconds and
|
|
72
|
+
// the NO_PROXY the invoking shell already had.
|
|
34
73
|
//
|
|
35
74
|
// MITM (forward-proxy) mode is the default, matching `teamclaude run`: it routes
|
|
36
75
|
// ALL of claude's traffic through the proxy — even hardcoded api.anthropic.com
|
|
@@ -101,7 +140,7 @@ export function buildCustomModelVars(customModels) {
|
|
|
101
140
|
return vars;
|
|
102
141
|
}
|
|
103
142
|
|
|
104
|
-
export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdSeconds = 0, account = null, proxyApiKey = '', customModels = null }) {
|
|
143
|
+
export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdSeconds = 0, account = null, proxyApiKey = '', customModels = null, inheritedNoProxy = null }) {
|
|
105
144
|
const lines = [];
|
|
106
145
|
const pin = (account || '').trim();
|
|
107
146
|
// The port is interpolated unquoted into URLs the shell evals, so it has to
|
|
@@ -111,13 +150,16 @@ export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdS
|
|
|
111
150
|
if (useMitm) {
|
|
112
151
|
const userinfo = pin ? `${encodePinComponent(pin)}:${encodePinComponent(proxyApiKey || '')}@` : '';
|
|
113
152
|
const proxyUrl = `http://${userinfo}127.0.0.1:${port}`;
|
|
153
|
+
const noProxy = mergeNoProxy(inheritedNoProxy);
|
|
114
154
|
lines.push(
|
|
115
155
|
`export HTTPS_PROXY=${proxyUrl}`,
|
|
116
156
|
`export HTTP_PROXY=${proxyUrl}`,
|
|
117
157
|
`export https_proxy=${proxyUrl}`,
|
|
118
158
|
`export http_proxy=${proxyUrl}`,
|
|
119
|
-
|
|
120
|
-
'
|
|
159
|
+
// Quoted like the CA path below: the value now carries whatever the
|
|
160
|
+
// operator's own NO_PROXY held, and this line is eval'd.
|
|
161
|
+
`export NO_PROXY=${shellQuote(noProxy)}`,
|
|
162
|
+
`export no_proxy=${shellQuote(noProxy)}`,
|
|
121
163
|
);
|
|
122
164
|
// Quoted: the path is under $HOME (or XDG_CONFIG_HOME), which can carry a
|
|
123
165
|
// space or a quote, and this line is eval'd.
|
package/src/index.js
CHANGED
|
@@ -39,7 +39,7 @@ import { autoUpdate, checkForUpdate, currentVersion, runUpdate, installKind, PKG
|
|
|
39
39
|
import { renderStatus, formatPercent } from './status-renderer.js';
|
|
40
40
|
import { sanitizeText } from './safe-text.js';
|
|
41
41
|
import { ClientUsageTracker, UsageDimensionTracker } from './client-usage.js';
|
|
42
|
-
import { buildClaudeEnvLines, buildCustomModelAgents, buildCustomModelSettings, buildCustomModelVars, encodePinComponent } from './claude-env.js';
|
|
42
|
+
import { buildClaudeEnvLines, buildCustomModelAgents, buildCustomModelSettings, buildCustomModelVars, bypassesAllHosts, encodePinComponent, mergeNoProxy } from './claude-env.js';
|
|
43
43
|
import { serviceKind, installService, uninstallService, serviceStatus, renderService, logPath } from './service.js';
|
|
44
44
|
import { formatTerminalTitle, titleSequence, TITLE_STACK_PUSH, TITLE_STACK_POP } from './terminal-title.js';
|
|
45
45
|
import { getUpstreamProxy, describeProxy, describeSelfProxy } from './upstream-proxy.js';
|
|
@@ -953,6 +953,9 @@ async function envCommand() {
|
|
|
953
953
|
port, useMitm, caPath, holdSeconds: config.holdSeconds,
|
|
954
954
|
account, proxyApiKey: config.proxy?.apiKey || '',
|
|
955
955
|
customModels: config.customModels,
|
|
956
|
+
// The shell doing the eval keeps its own NO_PROXY entries; re-running is
|
|
957
|
+
// idempotent, since the merged value is what it will have next time.
|
|
958
|
+
inheritedNoProxy: [process.env.NO_PROXY, process.env.no_proxy].filter(Boolean).join(','),
|
|
956
959
|
});
|
|
957
960
|
} catch (err) {
|
|
958
961
|
// A bad proxy.port. Nothing reaches stdout: the shell is eval'ing it.
|
|
@@ -1032,7 +1035,13 @@ async function runCommand() {
|
|
|
1032
1035
|
: '';
|
|
1033
1036
|
const proxyUrl = `http://${userinfo}127.0.0.1:${port}`;
|
|
1034
1037
|
env.HTTPS_PROXY = env.HTTP_PROXY = env.https_proxy = env.http_proxy = proxyUrl;
|
|
1035
|
-
|
|
1038
|
+
// Keep the operator's own NO_PROXY and add ours — see mergeNoProxy. Both
|
|
1039
|
+
// spellings are read: a tool that set only one still meant it.
|
|
1040
|
+
const inheritedNoProxy = [process.env.NO_PROXY, process.env.no_proxy];
|
|
1041
|
+
env.NO_PROXY = env.no_proxy = mergeNoProxy(...inheritedNoProxy);
|
|
1042
|
+
if (inheritedNoProxy.some(bypassesAllHosts)) {
|
|
1043
|
+
console.error('[TeamClaude] NO_PROXY=* ignored: it would send api.anthropic.com around the proxy (no rotation). Use --no-mitm for a direct launch.');
|
|
1044
|
+
}
|
|
1036
1045
|
env.NODE_EXTRA_CA_CERTS = caPath;
|
|
1037
1046
|
if (tcAcct) console.error(`[TeamClaude] Pinned to account "${tcAcct}" (TC_ACCT)`);
|
|
1038
1047
|
else if (pinnedBase) {
|
package/src/provider.js
CHANGED
|
@@ -199,3 +199,23 @@ export function upstreamFor(account, configuredUpstream) {
|
|
|
199
199
|
export function rewritesBody(account) {
|
|
200
200
|
return PROVIDERS[providerOf(account)].rewritesBody;
|
|
201
201
|
}
|
|
202
|
+
|
|
203
|
+
/** Whether `account` is served by a process on this machine rather than by a
|
|
204
|
+
* vendor endpoint — typically a local translating proxy in front of another
|
|
205
|
+
* backend.
|
|
206
|
+
*
|
|
207
|
+
* Keyed on the upstream resolving to loopback. Pairing the account against a
|
|
208
|
+
* declared local process does not generalise: such a declaration carries a
|
|
209
|
+
* COMMAND rather than a port, and the port sits inside its argv, where every
|
|
210
|
+
* program spells it differently. A loopback upstream says the same thing
|
|
211
|
+
* directly, and says it for a hand-started process too. A remote third-party
|
|
212
|
+
* backend (DeepSeek, GLM) keeps a public host and is not caught.
|
|
213
|
+
*/
|
|
214
|
+
export function isLocalUpstream(account) {
|
|
215
|
+
if (!account?.upstream) return false;
|
|
216
|
+
let hostname;
|
|
217
|
+
try { hostname = new URL(account.upstream).hostname; }
|
|
218
|
+
catch { return false; } // not a URL we can judge — treat it as a normal account
|
|
219
|
+
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase(); // URL brackets IPv6
|
|
220
|
+
return host === 'localhost' || host === '::1' || /^127\./.test(host);
|
|
221
|
+
}
|
package/src/tui.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolveMaxUsage } from './model.js';
|
|
|
15
15
|
import { formatProjection } from './quota-projection.js';
|
|
16
16
|
import { parseProxyUrl, proxyToUrl, describeProxy, describeSelfProxy, resolveUpstreamProxy, setUpstreamProxy, getUpstreamProxy } from './upstream-proxy.js';
|
|
17
17
|
import { sanitizeText, safeLine } from './safe-text.js';
|
|
18
|
+
import { isLocalUpstream } from './provider.js';
|
|
18
19
|
|
|
19
20
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -757,7 +758,7 @@ export class TUI {
|
|
|
757
758
|
label: 'Remove account',
|
|
758
759
|
hint: 'Enter to pick',
|
|
759
760
|
value: () => dim('—'),
|
|
760
|
-
enter: () => { this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0; this.selReturn = 'settings'; },
|
|
761
|
+
enter: () => { this.mode = 'select'; this.selAction = 'remove'; this.selIdx = this._displayOrder()[0] ?? 0; this.selReturn = 'settings'; },
|
|
761
762
|
});
|
|
762
763
|
}
|
|
763
764
|
|
|
@@ -910,9 +911,13 @@ export class TUI {
|
|
|
910
911
|
}
|
|
911
912
|
|
|
912
913
|
_keySelect(k) {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
914
|
+
// Step through the rows AS DRAWN (_displayOrder), while selIdx itself stays
|
|
915
|
+
// a manager index — everything it feeds (switch, toggle, remove, route pins)
|
|
916
|
+
// addresses an account by that index, not by its position on screen.
|
|
917
|
+
const order = this._displayOrder();
|
|
918
|
+
const pos = order.indexOf(this.selIdx);
|
|
919
|
+
if (k === 'up' || k === 'k') this.selIdx = order[Math.max(0, pos - 1)] ?? this.selIdx;
|
|
920
|
+
else if (k === 'down' || k === 'j') this.selIdx = order[Math.min(order.length - 1, pos + 1)] ?? this.selIdx;
|
|
916
921
|
// Tab / ←→ (switch only): cycle which route the pick applies to. null = the
|
|
917
922
|
// global default account; each getRoutes() entry = a per-route manual pin.
|
|
918
923
|
// ↑↓ move within the account list, so ←→ are free to move across targets.
|
|
@@ -1504,7 +1509,7 @@ export class TUI {
|
|
|
1504
1509
|
fable: anyFable ? this.am.previewRouteIndex('claude-fable-5') : null,
|
|
1505
1510
|
sonnet: anySonnet ? this.am.previewRouteIndex('claude-sonnet-4-6') : null,
|
|
1506
1511
|
};
|
|
1507
|
-
for (
|
|
1512
|
+
for (const i of this._displayOrder()) {
|
|
1508
1513
|
const b = budgets.get(categoryOf(this.am.accounts[i]));
|
|
1509
1514
|
lines.push(this._renderAcct(i, b.bw, b.showBoth, routes, genRoutes, familyTarget, b.showFamily, nameW));
|
|
1510
1515
|
}
|
|
@@ -1559,6 +1564,29 @@ export class TUI {
|
|
|
1559
1564
|
this._paint(buf, force);
|
|
1560
1565
|
}
|
|
1561
1566
|
|
|
1567
|
+
/** Manager indices in the order the rows are drawn: accounts served by a
|
|
1568
|
+
* local process last, every other account left where it is.
|
|
1569
|
+
*
|
|
1570
|
+
* A local backend (a translating proxy in front of another vendor, say) is
|
|
1571
|
+
* infrastructure rather than a seat to rotate between, so it reads as noise
|
|
1572
|
+
* wedged among the accounts that do rotate. Config order cannot keep it out
|
|
1573
|
+
* of the way on its own, because a newly added account is appended AFTER it
|
|
1574
|
+
* and puts it back in the middle.
|
|
1575
|
+
*
|
|
1576
|
+
* Display only. `selIdx`, `currentIndex`, session pins and route entries all
|
|
1577
|
+
* stay manager indices, so nothing about selection or routing moves with the
|
|
1578
|
+
* rows — see _keySelect, which walks this order but still stores an index.
|
|
1579
|
+
*/
|
|
1580
|
+
_displayOrder() {
|
|
1581
|
+
return this.am.accounts
|
|
1582
|
+
.map((_, i) => i)
|
|
1583
|
+
.sort((x, y) => {
|
|
1584
|
+
const sx = isLocalUpstream(this.am.accounts[x]) ? 1 : 0;
|
|
1585
|
+
const sy = isLocalUpstream(this.am.accounts[y]) ? 1 : 0;
|
|
1586
|
+
return sx - sy || x - y; // ties keep list order, so the sort is stable
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1562
1590
|
_renderAcct(idx, bw, showBoth, routes = this.am.getRoutes(), genRoutes = routes.filter(r => routeFamily(r) === null), familyTarget = {}, showFamily = true, nameW = NAME_MIN) {
|
|
1563
1591
|
const a = this.am.accounts[idx];
|
|
1564
1592
|
const isCur = idx === this.am.currentIndex;
|