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.
Files changed (3) hide show
  1. package/README.md +42 -4
  2. package/bin/virouter.mjs +311 -18
  3. 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. In `~/.claude/settings.json`
54
- it sets only the `model` key (and drops an `env.ANTHROPIC_MODEL` pin); every other setting is
55
- kept. If it detects ClaudeKit or ECC, it reports that configuration and otherwise only writes
56
- gateway environment variables plus the OpenCode provider entry.
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
- ? [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]
108
- : [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')];
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 configureWindows(apiKey, pinnedModel) {
250
- if (!pinnedModel) {
251
- // An empty `setx` value is still a variable; delete the old pin outright so `/model` in
252
- // ~/.claude/settings.json decides. Absent already is fine.
253
- try {
254
- execFileSync('reg', ['delete', 'HKCU\\Environment', '/v', 'ANTHROPIC_MODEL', '/f'], { stdio: 'ignore' });
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 === '2'
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(' and ');
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
- writeClaudeSettings(claudeSettings.path, withRememberedModel(claudeSettings.settings, model));
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virouter",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Configure Claude Code to use the VIROUTER API gateway",
5
5
  "keywords": ["claude-code", "anthropic", "openai", "api-gateway", "cli"],
6
6
  "license": "MIT",