virouter 0.2.5 → 0.2.6
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 +42 -4
- package/bin/virouter.mjs +311 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,10 +50,48 @@ preserved; VIROUTER is appended and selected as the default provider. A new conf
|
|
|
50
50
|
enables only VIROUTER. This allows existing ClaudeKit/ECC/OpenCode workflows to continue
|
|
51
51
|
without losing their provider setup.
|
|
52
52
|
|
|
53
|
-
The installer never replaces `.claude/` skills, rules, agents, or hooks.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
The installer never replaces `.claude/` skills, rules, agents, or hooks.
|
|
54
|
+
|
|
55
|
+
### Taking over a machine that already ran Claude Code elsewhere
|
|
56
|
+
|
|
57
|
+
An `env` block in `~/.claude/settings.json` beats the shell — Claude Code writes each entry into
|
|
58
|
+
its own environment, replacing what `~/.zshrc` or the installer's launch passed in. A machine that
|
|
59
|
+
another tool once pointed at its own proxy or gateway therefore kept going there (a dead
|
|
60
|
+
`127.0.0.1` port shows up as `ECONNREFUSED`) however often the shell profile was rewritten.
|
|
61
|
+
|
|
62
|
+
Since 0.2.6 the installer writes the route where it wins, in `~/.claude/settings.json`:
|
|
63
|
+
|
|
64
|
+
- `env.ANTHROPIC_BASE_URL` and `env.ANTHROPIC_AUTH_TOKEN` — replacing whatever was there, and
|
|
65
|
+
reported (`Thay địa chỉ cũ …`). This is also what the VS Code extension and other GUI launches
|
|
66
|
+
read, since they never source a shell profile.
|
|
67
|
+
- removes `env.ANTHROPIC_API_KEY`, `env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` and the
|
|
68
|
+
cloud-provider switches `CLAUDE_CODE_USE_BEDROCK` / `_VERTEX` / `_FOUNDRY`, which outrank the
|
|
69
|
+
token outright; one the shell exports is pinned to `0`, since a settings file cannot unset it.
|
|
70
|
+
- copies the old file to `settings.json.bak-virouter-<timestamp>` first. Every other key is kept.
|
|
71
|
+
|
|
72
|
+
### VS Code, Cursor, Windsurf
|
|
73
|
+
|
|
74
|
+
The Claude Code extension reads the route from `~/.claude/settings.json`, but opens on its
|
|
75
|
+
Anthropic sign-in screen unless `claudeCode.disableLoginPrompt` is on. The installer sets it in
|
|
76
|
+
the user `settings.json` of every installed editor (VS Code, Insiders, Cursor, Windsurf,
|
|
77
|
+
VSCodium), with a backup. A file with comments is not rewritten — the installer says so and the
|
|
78
|
+
setting has to be ticked by hand. `--doctor` reports it per editor.
|
|
79
|
+
|
|
80
|
+
macOS also gets the block in `~/.bash_profile`, the file a bash login shell actually reads.
|
|
81
|
+
|
|
82
|
+
What the installer cannot change it reports before launching: an organisation's managed settings
|
|
83
|
+
(`/Library/Application Support/ClaudeCode/managed-settings.json`, `/etc/claude-code/…`,
|
|
84
|
+
`C:\Program Files\ClaudeCode\…`), a project's own `.claude/settings*.json` that sets a URL or key,
|
|
85
|
+
stale exports elsewhere in shell profiles, and a proxy variable (`HTTPS_PROXY`, …) pointing at
|
|
86
|
+
nothing — Node's `fetch` ignores those, so the key check passes while Claude Code is refused.
|
|
87
|
+
|
|
88
|
+
To see what decides the URL and key on a machine, without changing anything:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx virouter@latest --doctor
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If it detects ClaudeKit or ECC, it reports that configuration and leaves it in place.
|
|
57
95
|
|
|
58
96
|
The customer key is never stored in this package or repository. Obtain it from the one-time
|
|
59
97
|
claim/recovery flow documented in `docs/claude-code-setup.md`; do not use an upstream
|
package/bin/virouter.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { connect } from 'node:net';
|
|
5
6
|
import { arch, homedir, hostname, release } from 'node:os';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -28,6 +29,17 @@ const VIROUTER_MODELS = [
|
|
|
28
29
|
'claude-haiku-4-5-alt',
|
|
29
30
|
];
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Variables that take Claude Code away from VIROUTER when another tool left them behind. The
|
|
34
|
+
* cloud-provider switches outrank ANTHROPIC_AUTH_TOKEN outright (Claude Code's authentication
|
|
35
|
+
* precedence), so with one of them set our base URL and key are never used at all.
|
|
36
|
+
*/
|
|
37
|
+
const CLOUD_PROVIDER_SWITCHES = ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY'];
|
|
38
|
+
const STALE_ENV = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', ...CLOUD_PROVIDER_SWITCHES];
|
|
39
|
+
/** What Claude Code reads to decide where requests go and how they authenticate. */
|
|
40
|
+
const ROUTING_ENV = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', ...CLOUD_PROVIDER_SWITCHES];
|
|
41
|
+
const PROXY_ENV = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
|
|
42
|
+
|
|
31
43
|
function printHelp() {
|
|
32
44
|
console.log(`VIROUTER Claude Code + OpenCode installer
|
|
33
45
|
|
|
@@ -36,6 +48,7 @@ Usage:
|
|
|
36
48
|
npx virouter@latest --help
|
|
37
49
|
npx virouter@latest --no-launch
|
|
38
50
|
npx virouter@latest --reuse-key
|
|
51
|
+
npx virouter@latest --doctor # what decides Claude Code's URL and key on this machine
|
|
39
52
|
|
|
40
53
|
The installer configures Claude Code and OpenCode to use ${API_ORIGIN}.
|
|
41
54
|
For local testing, set VIROUTER_API_ORIGIN=http://127.0.0.1:8787.
|
|
@@ -104,8 +117,8 @@ function existingConfig() {
|
|
|
104
117
|
const candidates = process.platform === 'win32'
|
|
105
118
|
? []
|
|
106
119
|
: process.platform === 'darwin'
|
|
107
|
-
?
|
|
108
|
-
:
|
|
120
|
+
? profilePaths('2')
|
|
121
|
+
: profilePaths('3');
|
|
109
122
|
const sources = candidates
|
|
110
123
|
.filter((path) => existsSync(path))
|
|
111
124
|
.map((path) => readFileSync(path, 'utf8'));
|
|
@@ -170,11 +183,118 @@ export function withRememberedModel(settings, model) {
|
|
|
170
183
|
return { ...settings, model, env };
|
|
171
184
|
}
|
|
172
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Settings that route Claude Code to VIROUTER on any machine, whatever was there before.
|
|
188
|
+
*
|
|
189
|
+
* An `env` block in a settings file beats the shell: Claude Code writes each entry into its
|
|
190
|
+
* process environment, replacing what the shell (or this installer's launch) passed in. So a
|
|
191
|
+
* machine that once pointed ~/.claude/settings.json at another proxy or gateway kept going
|
|
192
|
+
* there — or to a dead localhost port, ECONNREFUSED — however many times the installer
|
|
193
|
+
* rewrote ~/.zshrc. The URL and key therefore live here, where they win, and where the VS Code
|
|
194
|
+
* extension and other GUI launches, which never read a shell profile, can see them too.
|
|
195
|
+
*
|
|
196
|
+
* Returns the new settings, the stale variables it dropped, the cloud-provider switches it
|
|
197
|
+
* pinned off (one exported by the shell cannot be removed from here) and the URL it replaced.
|
|
198
|
+
*/
|
|
199
|
+
export function withVirouterSettings(settings, { apiKey, model, origin = API_ORIGIN, shellEnv = process.env }) {
|
|
200
|
+
const remembered = withRememberedModel(settings, model);
|
|
201
|
+
const env = { ...remembered.env };
|
|
202
|
+
const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === 'string' && env.ANTHROPIC_BASE_URL !== origin
|
|
203
|
+
? env.ANTHROPIC_BASE_URL
|
|
204
|
+
: null;
|
|
205
|
+
const removed = STALE_ENV.filter((name) => name in env);
|
|
206
|
+
for (const name of removed) delete env[name];
|
|
207
|
+
const disabled = CLOUD_PROVIDER_SWITCHES.filter((name) => isTruthy(shellEnv[name]));
|
|
208
|
+
for (const name of disabled) env[name] = '0';
|
|
209
|
+
Object.assign(env, {
|
|
210
|
+
ANTHROPIC_BASE_URL: origin,
|
|
211
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
212
|
+
ANTHROPIC_SMALL_FAST_MODEL: 'claude-haiku-4-5',
|
|
213
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
214
|
+
});
|
|
215
|
+
return { settings: { ...remembered, env }, removed, disabled, previousBaseUrl };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isTruthy(value) {
|
|
219
|
+
return typeof value === 'string' && /^(1|true|yes|on)$/i.test(value.trim());
|
|
220
|
+
}
|
|
221
|
+
|
|
173
222
|
function writeClaudeSettings(path, settings) {
|
|
174
223
|
mkdirSync(join(path, '..'), { recursive: true });
|
|
224
|
+
if (existsSync(path)) {
|
|
225
|
+
// The customer's own settings are worth a copy before we rewrite them.
|
|
226
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '');
|
|
227
|
+
copyFileSync(path, `${path}.bak-virouter-${stamp}`);
|
|
228
|
+
}
|
|
175
229
|
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
176
230
|
}
|
|
177
231
|
|
|
232
|
+
/** VS Code and its forks that host the Claude Code extension, by user-data folder name. */
|
|
233
|
+
const EDITORS = ['Code', 'Code - Insiders', 'Cursor', 'Windsurf', 'VSCodium'];
|
|
234
|
+
const LOGIN_PROMPT_SETTING = 'claudeCode.disableLoginPrompt';
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Where each editor keeps its user settings.json. Only editors whose user-data folder already
|
|
238
|
+
* exists are returned — the installer never creates an editor's folder.
|
|
239
|
+
*/
|
|
240
|
+
export function editorSettingsPaths({ platform = process.platform, home = homedir(), env = process.env } = {}) {
|
|
241
|
+
const base = platform === 'win32'
|
|
242
|
+
? env.APPDATA || join(home, 'AppData', 'Roaming')
|
|
243
|
+
: platform === 'darwin'
|
|
244
|
+
? join(home, 'Library', 'Application Support')
|
|
245
|
+
: env.XDG_CONFIG_HOME || join(home, '.config');
|
|
246
|
+
return EDITORS
|
|
247
|
+
.filter((name) => existsSync(join(base, name)))
|
|
248
|
+
.map((name) => ({ name, path: join(base, name, 'User', 'settings.json') }));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The editor setting the Claude Code extension needs to talk to a gateway at all.
|
|
253
|
+
*
|
|
254
|
+
* Without it the extension opens on its Anthropic sign-in screen and never starts a session,
|
|
255
|
+
* however correct the URL and key in ~/.claude/settings.json are — the CLI in the same
|
|
256
|
+
* editor's terminal works, the panel does not. Returns null when the file is not plain JSON
|
|
257
|
+
* (VS Code allows comments): rewriting it would drop them, so that case is left to the user.
|
|
258
|
+
*/
|
|
259
|
+
export function withLoginPromptDisabled(text) {
|
|
260
|
+
if (!text.trim()) return { [LOGIN_PROMPT_SETTING]: true };
|
|
261
|
+
let settings;
|
|
262
|
+
try {
|
|
263
|
+
settings = JSON.parse(text);
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null;
|
|
268
|
+
return { ...settings, [LOGIN_PROMPT_SETTING]: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Sets the login-prompt switch in every installed editor. Returns what happened per editor. */
|
|
272
|
+
function configureEditors() {
|
|
273
|
+
return editorSettingsPaths().map(({ name, path }) => {
|
|
274
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
275
|
+
const next = withLoginPromptDisabled(text);
|
|
276
|
+
if (!next) return { name, path, outcome: 'manual' };
|
|
277
|
+
if (text.trim() && JSON.parse(text)[LOGIN_PROMPT_SETTING] === true) return { name, path, outcome: 'already' };
|
|
278
|
+
try {
|
|
279
|
+
writeClaudeSettings(path, next);
|
|
280
|
+
return { name, path, outcome: 'set' };
|
|
281
|
+
} catch {
|
|
282
|
+
return { name, path, outcome: 'manual' };
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function reportEditors(results) {
|
|
288
|
+
for (const { name, path, outcome } of results) {
|
|
289
|
+
if (outcome === 'manual') {
|
|
290
|
+
console.log(`${style.yellow('!')} ${name}: không tự sửa được ${path} (có comment hoặc không ghi được).`);
|
|
291
|
+
console.log(style.dim(' Mở Settings, tìm "Disable Login Prompt" (Claude Code) và tích chọn — nếu không, extension sẽ đòi đăng nhập Anthropic.'));
|
|
292
|
+
} else {
|
|
293
|
+
step(`${name} extension`, `${LOGIN_PROMPT_SETTING} = true${outcome === 'set' ? ' — tải lại cửa sổ (Developer: Reload Window)' : ''}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
178
298
|
function detectAgentToolkit() {
|
|
179
299
|
const candidates = [
|
|
180
300
|
join(process.cwd(), '.claude', '.ck.json'),
|
|
@@ -234,6 +354,7 @@ function configLines(apiKey, pinnedModel) {
|
|
|
234
354
|
// traffic must stay enabled or discovery is skipped.
|
|
235
355
|
'export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY="1"',
|
|
236
356
|
'unset CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
|
357
|
+
`unset ${CLOUD_PROVIDER_SWITCHES.join(' ')}`,
|
|
237
358
|
];
|
|
238
359
|
}
|
|
239
360
|
|
|
@@ -246,16 +367,19 @@ function updateProfile(path, lines) {
|
|
|
246
367
|
return path;
|
|
247
368
|
}
|
|
248
369
|
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
} catch {
|
|
256
|
-
// Nothing to delete.
|
|
257
|
-
}
|
|
370
|
+
function deleteWindowsUserVariable(name) {
|
|
371
|
+
// An empty `setx` value is still a variable; delete it outright. Absent already is fine.
|
|
372
|
+
try {
|
|
373
|
+
execFileSync('reg', ['delete', 'HKCU\\Environment', '/v', name, '/f'], { stdio: 'ignore' });
|
|
374
|
+
} catch {
|
|
375
|
+
// Nothing to delete.
|
|
258
376
|
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function configureWindows(apiKey, pinnedModel) {
|
|
380
|
+
// Without the pin, `/model` in ~/.claude/settings.json decides.
|
|
381
|
+
if (!pinnedModel) deleteWindowsUserVariable('ANTHROPIC_MODEL');
|
|
382
|
+
for (const name of CLOUD_PROVIDER_SWITCHES) deleteWindowsUserVariable(name);
|
|
259
383
|
const values = {
|
|
260
384
|
ANTHROPIC_BASE_URL: API_ORIGIN,
|
|
261
385
|
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
@@ -326,14 +450,169 @@ function configureOpenCode(apiKey, model) {
|
|
|
326
450
|
return path;
|
|
327
451
|
}
|
|
328
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Shell files the config block goes into. macOS Terminal starts bash as a login shell, which
|
|
455
|
+
* reads ~/.bash_profile and never ~/.bashrc — a bash user on a Mac got no configuration.
|
|
456
|
+
*/
|
|
457
|
+
export function profilePaths(os, home = homedir()) {
|
|
458
|
+
return os === '2'
|
|
459
|
+
? [join(home, '.zshrc'), join(home, '.bash_profile'), join(home, '.bashrc')]
|
|
460
|
+
: [join(home, '.bashrc'), join(home, '.zshrc')];
|
|
461
|
+
}
|
|
462
|
+
|
|
329
463
|
function configureUnix(os, apiKey, pinnedModel) {
|
|
330
464
|
const lines = configLines(apiKey, pinnedModel);
|
|
331
|
-
const profiles = os
|
|
332
|
-
? [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]
|
|
333
|
-
: [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')];
|
|
334
|
-
|
|
465
|
+
const profiles = profilePaths(os);
|
|
335
466
|
for (const profile of profiles) updateProfile(profile, lines);
|
|
336
|
-
return profiles.join('
|
|
467
|
+
return profiles.map((path) => path.replace(homedir(), '~')).join(', ');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Where an organisation's policy file lives. Nothing a user writes can override it. */
|
|
471
|
+
export function managedSettingsPath(platform = process.platform) {
|
|
472
|
+
if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json';
|
|
473
|
+
if (platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\managed-settings.json';
|
|
474
|
+
return '/etc/claude-code/managed-settings.json';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function readJson(path) {
|
|
478
|
+
try {
|
|
479
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
480
|
+
} catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Everything on this machine that can still decide Claude Code's URL or key after we have
|
|
487
|
+
* written ours, highest precedence first. `block` cannot be fixed by the installer (an
|
|
488
|
+
* organisation's managed settings); `warn` wins over ~/.claude/settings.json in some sessions
|
|
489
|
+
* (a project's own settings); `info` is shadowed by our settings but worth knowing about.
|
|
490
|
+
*/
|
|
491
|
+
export function findOverrides({ cwd = process.cwd(), home = homedir(), platform = process.platform, managedPath } = {}) {
|
|
492
|
+
const found = [];
|
|
493
|
+
const routingKeys = (env) => (env && typeof env === 'object' ? ROUTING_ENV.filter((name) => name in env) : []);
|
|
494
|
+
|
|
495
|
+
const managedFile = managedPath ?? managedSettingsPath(platform);
|
|
496
|
+
const managed = existsSync(managedFile) ? readJson(managedFile) : null;
|
|
497
|
+
if (managed) {
|
|
498
|
+
const keys = routingKeys(managed.env);
|
|
499
|
+
if (keys.length) found.push({ level: 'block', where: managedFile, detail: `đặt ${keys.join(', ')}` });
|
|
500
|
+
if (managed.forceLoginMethod) found.push({ level: 'block', where: managedFile, detail: `forceLoginMethod=${managed.forceLoginMethod}` });
|
|
501
|
+
if (Array.isArray(managed.allowedProviders) && !managed.allowedProviders.includes('customEndpoint')) {
|
|
502
|
+
found.push({ level: 'block', where: managedFile, detail: 'allowedProviders không cho phép gateway riêng' });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
for (const name of ['settings.local.json', 'settings.json']) {
|
|
507
|
+
const path = join(cwd, '.claude', name);
|
|
508
|
+
if (path === join(home, '.claude', name) || !existsSync(path)) continue;
|
|
509
|
+
const keys = routingKeys(readJson(path)?.env);
|
|
510
|
+
if (keys.length) found.push({ level: 'warn', where: path, detail: `đặt ${keys.join(', ')} cho riêng dự án này` });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (platform !== 'win32') {
|
|
514
|
+
const exported = new RegExp(`^\\s*(?:export\\s+|set\\s+-gx\\s+)?(${ROUTING_ENV.join('|')})[=\\s]`);
|
|
515
|
+
for (const file of ['.zshenv', '.zprofile', '.zshrc', '.zlogin', '.bash_profile', '.bashrc', '.profile', join('.config', 'fish', 'config.fish')]) {
|
|
516
|
+
const path = join(home, file);
|
|
517
|
+
if (!existsSync(path)) continue;
|
|
518
|
+
let inside = false;
|
|
519
|
+
readFileSync(path, 'utf8').split('\n').forEach((line, index) => {
|
|
520
|
+
if (line.includes(MARKER_START)) inside = true;
|
|
521
|
+
const name = !inside && exported.exec(line)?.[1];
|
|
522
|
+
if (name) found.push({ level: 'info', where: `${path}:${index + 1}`, detail: `${name} ngoài khối VIROUTER` });
|
|
523
|
+
if (line.includes(MARKER_END)) inside = false;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return found;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Proxy variables Claude Code will use, from the shell or a settings `env` block. */
|
|
531
|
+
export function proxySettings(env = process.env, settingsEnv = {}) {
|
|
532
|
+
const merged = { ...env, ...(settingsEnv && typeof settingsEnv === 'object' ? settingsEnv : {}) };
|
|
533
|
+
return PROXY_ENV.filter((name) => merged[name]).map((name) => ({ name, url: merged[name] }));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Whether anything is listening at a proxy URL. Node's fetch ignores HTTPS_PROXY, so the key
|
|
538
|
+
* check below succeeds even when the proxy is dead — while Claude Code, which honours it, gets
|
|
539
|
+
* ECONNREFUSED on every request ("a firewall or proxy may be blocking it").
|
|
540
|
+
*/
|
|
541
|
+
export function probeProxy(url, timeoutMs = 1500) {
|
|
542
|
+
let target;
|
|
543
|
+
try {
|
|
544
|
+
target = new URL(url.includes('://') ? url : `http://${url}`);
|
|
545
|
+
} catch {
|
|
546
|
+
return Promise.resolve('invalid');
|
|
547
|
+
}
|
|
548
|
+
const port = Number(target.port) || (target.protocol === 'https:' ? 443 : 80);
|
|
549
|
+
return new Promise((resolve) => {
|
|
550
|
+
const socket = connect({ host: target.hostname, port });
|
|
551
|
+
const done = (verdict) => {
|
|
552
|
+
socket.destroy();
|
|
553
|
+
resolve(verdict);
|
|
554
|
+
};
|
|
555
|
+
socket.setTimeout(timeoutMs, () => done('timeout'));
|
|
556
|
+
socket.once('connect', () => done('ok'));
|
|
557
|
+
socket.once('error', (error) => done(error.code === 'ECONNREFUSED' ? 'refused' : 'error'));
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function reportOverrides(settingsEnv) {
|
|
562
|
+
const found = findOverrides();
|
|
563
|
+
const proxies = proxySettings(process.env, settingsEnv);
|
|
564
|
+
const dead = [];
|
|
565
|
+
for (const proxy of proxies) {
|
|
566
|
+
const verdict = await probeProxy(proxy.url);
|
|
567
|
+
if (verdict !== 'ok') dead.push({ ...proxy, verdict });
|
|
568
|
+
}
|
|
569
|
+
for (const item of found) {
|
|
570
|
+
const mark = item.level === 'info' ? style.dim('·') : style.yellow('!');
|
|
571
|
+
console.log(`${mark} ${item.detail} ${style.dim(`— ${item.where}`)}`);
|
|
572
|
+
}
|
|
573
|
+
if (found.some((item) => item.level === 'block')) {
|
|
574
|
+
console.log(style.yellow(' Cấu hình do tổ chức quản lý (managed settings) luôn thắng — cần IT gỡ hoặc cho phép gateway riêng.'));
|
|
575
|
+
}
|
|
576
|
+
if (found.some((item) => item.level === 'warn')) {
|
|
577
|
+
console.log(style.dim(' File .claude/settings*.json của dự án thắng cấu hình chung — xóa các biến đó trong file nếu dự án không cần.'));
|
|
578
|
+
}
|
|
579
|
+
for (const proxy of dead) {
|
|
580
|
+
console.log(`${style.yellow('!')} Proxy ${proxy.name}=${proxy.url} không phản hồi (${proxy.verdict}) — Claude Code sẽ báo ECONNREFUSED.`);
|
|
581
|
+
}
|
|
582
|
+
if (dead.length) {
|
|
583
|
+
console.log(style.dim(` Bật lại phần mềm proxy, hoặc xóa ${dead.map((proxy) => proxy.name).join(', ')} khỏi shell profile / settings.json.`));
|
|
584
|
+
}
|
|
585
|
+
return { found, dead };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function doctor() {
|
|
589
|
+
console.log(`\n${style.bold(style.cyan('VIROUTER'))} ${style.bold('· doctor')}`);
|
|
590
|
+
console.log(style.dim(`Gateway ${API_ORIGIN}`));
|
|
591
|
+
const { path, settings, writable } = readClaudeSettings();
|
|
592
|
+
const env = settings.env && typeof settings.env === 'object' ? settings.env : {};
|
|
593
|
+
if (!writable) console.log(`${style.yellow('!')} ${path} không phải JSON hợp lệ — Claude Code sẽ báo lỗi và bỏ qua nó.`);
|
|
594
|
+
const baseUrl = env.ANTHROPIC_BASE_URL ?? process.env.ANTHROPIC_BASE_URL ?? '(chưa đặt)';
|
|
595
|
+
const token = env.ANTHROPIC_AUTH_TOKEN ?? process.env.ANTHROPIC_AUTH_TOKEN;
|
|
596
|
+
const mark = baseUrl === API_ORIGIN ? style.green('✓') : style.yellow('!');
|
|
597
|
+
console.log(`${mark} ANTHROPIC_BASE_URL ${baseUrl} ${style.dim(env.ANTHROPIC_BASE_URL ? `(${path})` : '(shell)')}`);
|
|
598
|
+
console.log(`${token ? style.green('✓') : style.yellow('!')} ANTHROPIC_AUTH_TOKEN ${token ? maskKey(token) : '(chưa đặt)'}`);
|
|
599
|
+
for (const name of CLOUD_PROVIDER_SWITCHES) {
|
|
600
|
+
const value = env[name] ?? process.env[name];
|
|
601
|
+
if (isTruthy(value)) console.log(`${style.yellow('!')} ${name}=${value} — Claude Code sẽ dùng cloud provider thay vì VIROUTER.`);
|
|
602
|
+
}
|
|
603
|
+
for (const { name, path } of editorSettingsPaths()) {
|
|
604
|
+
const enabled = existsSync(path) && readJson(path)?.[LOGIN_PROMPT_SETTING] === true;
|
|
605
|
+
console.log(enabled
|
|
606
|
+
? `${style.green('✓')} ${name}: ${LOGIN_PROMPT_SETTING} = true`
|
|
607
|
+
: `${style.yellow('!')} ${name}: chưa bật ${LOGIN_PROMPT_SETTING} — extension sẽ đòi đăng nhập Anthropic ${style.dim(`(${path})`)}`);
|
|
608
|
+
}
|
|
609
|
+
await reportOverrides(env);
|
|
610
|
+
try {
|
|
611
|
+
const response = await fetch(`${API_ORIGIN}/v1/models`, { signal: AbortSignal.timeout(5000) });
|
|
612
|
+
console.log(`${style.green('✓')} Kết nối tới gateway ${style.dim(`(HTTP ${response.status})`)}`);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
console.log(`${style.red('✗')} Không kết nối được ${API_ORIGIN}: ${error instanceof Error ? error.message : error}`);
|
|
615
|
+
}
|
|
337
616
|
}
|
|
338
617
|
|
|
339
618
|
/** `{ ok: true, me }`, `{ ok: false }` when the gateway rejects the key, or null if unreachable. */
|
|
@@ -376,6 +655,7 @@ function launchClaude(apiKey, pinnedModel) {
|
|
|
376
655
|
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
377
656
|
};
|
|
378
657
|
delete env.ANTHROPIC_API_KEY;
|
|
658
|
+
for (const name of CLOUD_PROVIDER_SWITCHES) delete env[name];
|
|
379
659
|
// Inherited from an older install's shell, this would override settings.json for this run.
|
|
380
660
|
if (pinnedModel) env.ANTHROPIC_MODEL = pinnedModel;
|
|
381
661
|
else delete env.ANTHROPIC_MODEL;
|
|
@@ -395,6 +675,10 @@ async function main() {
|
|
|
395
675
|
printHelp();
|
|
396
676
|
return;
|
|
397
677
|
}
|
|
678
|
+
if (process.argv.includes('--doctor')) {
|
|
679
|
+
await doctor();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
398
682
|
|
|
399
683
|
const machine = detectMachine();
|
|
400
684
|
console.log(`\n${style.bold(style.cyan('VIROUTER'))} ${style.bold('· Claude Code installer')}`);
|
|
@@ -438,11 +722,18 @@ async function main() {
|
|
|
438
722
|
// Remember the model in settings.json; pin it in the environment only if that file is not
|
|
439
723
|
// ours to rewrite (unparseable), since a session with no model at all would be worse.
|
|
440
724
|
let pinnedModel = null;
|
|
725
|
+
let settingsEnv = {};
|
|
441
726
|
if (claudeSettings.writable) {
|
|
442
|
-
|
|
727
|
+
const next = withVirouterSettings(claudeSettings.settings, { apiKey, model });
|
|
728
|
+
writeClaudeSettings(claudeSettings.path, next.settings);
|
|
729
|
+
settingsEnv = next.settings.env;
|
|
730
|
+
if (next.previousBaseUrl) step('Thay địa chỉ cũ', `${next.previousBaseUrl} → ${API_ORIGIN}`);
|
|
731
|
+
if (next.removed.length) step('Gỡ biến cũ trong settings.json', next.removed.join(', '));
|
|
732
|
+
if (next.disabled.length) step('Tắt cloud provider đang bật trong shell', next.disabled.join(', '));
|
|
443
733
|
} else {
|
|
444
734
|
pinnedModel = model;
|
|
445
735
|
console.log(`${style.yellow('!')} Không đọc được ${claudeSettings.path} (không phải JSON hợp lệ).`);
|
|
736
|
+
console.log(style.yellow(' Nếu file đó đặt ANTHROPIC_BASE_URL, Claude Code sẽ không dùng VIROUTER — sửa JSON rồi chạy lại.'));
|
|
446
737
|
console.log(style.dim(' Model sẽ cố định qua biến môi trường; /model sẽ không được ghi nhớ giữa các phiên.'));
|
|
447
738
|
}
|
|
448
739
|
|
|
@@ -453,6 +744,8 @@ async function main() {
|
|
|
453
744
|
step('Claude Code', location);
|
|
454
745
|
step(`Model ${style.bold(model)}`, '— đổi bằng /model, lần mở sau giữ model đã chọn');
|
|
455
746
|
if (openCodeConfig) step('OpenCode', openCodeConfig);
|
|
747
|
+
reportEditors(configureEditors());
|
|
748
|
+
await reportOverrides(settingsEnv);
|
|
456
749
|
if (process.argv.includes('--no-launch')) {
|
|
457
750
|
console.log(`\nMở terminal mới rồi chạy ${style.cyan('claude')}.\n`);
|
|
458
751
|
} else {
|