virouter 0.2.4 → 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 +49 -12
  2. package/bin/virouter.mjs +428 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,16 +17,15 @@ $env:VIROUTER_API_ORIGIN="http://127.0.0.1:8787"
17
17
  npx virouter@latest
18
18
  ```
19
19
 
20
- On the first install, the installer displays the API key while you type it, asks for the operating
21
- system and default model, and configures:
22
-
23
- - `1` = Windows
24
- - `2` = macOS
25
- - `3` = Linux
20
+ On the first install, the installer detects the machine (Windows, macOS, Linux or WSL — it no
21
+ longer asks), checks the key against the gateway and shows it masked with its plan, credit and
22
+ expiry, asks for the default model, and configures:
26
23
 
27
24
  - `ANTHROPIC_BASE_URL=https://virouter.click`
28
25
  - `ANTHROPIC_AUTH_TOKEN`
29
26
  - `model` in `~/.claude/settings.json` — `claude-fable-5-1` on a first install
27
+ - `env.ANTHROPIC_DEFAULT_MODEL` in the same file, so the `/model` picker's "Default" row resolves
28
+ to a VIROUTER model instead of Claude Code's own default (a choice saved with `/model` still wins)
30
29
  - `ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5`
31
30
  - `VIROUTER_API_KEY` for OpenCode
32
31
  - `~/.config/opencode/opencode.json` with VIROUTER as the default provider
@@ -43,18 +42,56 @@ back to the install-time model. An install that pinned it is migrated — that m
43
42
  `settings.json`. Only if `settings.json` is not valid JSON does the installer leave it untouched
44
43
  and fall back to pinning `ANTHROPIC_MODEL`.
45
44
 
46
- The installer uses visible input and prints the received key for confirmation. Do not record or
47
- share that terminal output.
45
+ The key is visible while you type it; afterwards the installer only ever prints it masked
46
+ (`viro_AbCd…UvWx`). Do not record or share the terminal while typing it.
48
47
 
49
48
  When an OpenCode configuration already exists, its enabled providers and credentials are
50
49
  preserved; VIROUTER is appended and selected as the default provider. A new configuration
51
50
  enables only VIROUTER. This allows existing ClaudeKit/ECC/OpenCode workflows to continue
52
51
  without losing their provider setup.
53
52
 
54
- The installer never replaces `.claude/` skills, rules, agents, or hooks. In `~/.claude/settings.json`
55
- it sets only the `model` key (and drops an `env.ANTHROPIC_MODEL` pin); every other setting is
56
- kept. If it detects ClaudeKit or ECC, it reports that configuration and otherwise only writes
57
- 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.
58
95
 
59
96
  The customer key is never stored in this package or repository. Obtain it from the one-time
60
97
  claim/recovery flow documented in `docs/claude-code-setup.md`; do not use an upstream
package/bin/virouter.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from 'node:child_process';
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
- import { homedir } from 'node:os';
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
5
+ import { connect } from 'node:net';
6
+ import { arch, homedir, hostname, release } from 'node:os';
6
7
  import { join } from 'node:path';
7
8
  import { createInterface } from 'node:readline/promises';
8
- import { pathToFileURL } from 'node:url';
9
+ import { fileURLToPath } from 'node:url';
9
10
 
10
11
  const API_ORIGIN = (process.env.VIROUTER_API_ORIGIN || 'https://virouter.click').replace(/\/$/, '');
11
12
  const MARKER_START = '# >>> VIROUTER CLAUDE CODE CONFIG >>>';
@@ -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,36 +48,66 @@ 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.
42
55
  It does not install a server or store a local model.`);
43
56
  }
44
57
 
58
+ // Colour only on a real terminal, and never when NO_COLOR is set (https://no-color.org).
59
+ const USE_COLOR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
60
+ const paint = (code) => (text) => (USE_COLOR ? `\x1b[${code}m${text}\x1b[0m` : String(text));
61
+ const style = {
62
+ bold: paint('1'),
63
+ dim: paint('2'),
64
+ red: paint('31'),
65
+ green: paint('32'),
66
+ yellow: paint('33'),
67
+ cyan: paint('36'),
68
+ };
69
+
70
+ /** Enough of the key to recognise it, never enough to use it. */
71
+ export function maskKey(key) {
72
+ return key.length > 16 ? `${key.slice(0, 9)}…${key.slice(-4)}` : `${key.slice(0, 5)}…`;
73
+ }
74
+
75
+ /**
76
+ * The machine the installer is configuring. Detected rather than asked: picking the wrong
77
+ * number wrote a shell profile on Windows or `setx` on Linux, and neither failed loudly.
78
+ */
79
+ export function detectMachine(platform = process.platform) {
80
+ if (platform === 'win32') return { os: '1', name: 'Windows' };
81
+ if (platform === 'darwin') return { os: '2', name: 'macOS' };
82
+ let wsl = false;
83
+ try {
84
+ wsl = /microsoft/i.test(readFileSync('/proc/version', 'utf8'));
85
+ } catch {
86
+ // Not Linux proper, or /proc is unavailable; plain Linux it is.
87
+ }
88
+ return { os: '3', name: wsl ? 'Linux (WSL)' : 'Linux' };
89
+ }
90
+
91
+ function step(label, detail = '') {
92
+ console.log(`${style.green('✓')} ${label}${detail ? ` ${style.dim(detail)}` : ''}`);
93
+ }
94
+
45
95
  async function readVisibleInput(prompt) {
46
96
  const line = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
47
97
  const answer = await line.question(prompt);
48
98
  line.close();
49
- const value = answer.trim();
50
- console.log(`\nĐã nhận customer key: ${value}`);
51
- return value;
52
- }
53
-
54
- async function chooseOs(input) {
55
- console.log('Hệ điều hành:');
56
- console.log(' ● 1 = Windows');
57
- console.log(' ● 2 = macOS');
58
- console.log(' ● 3 = Linux');
59
- const answer = (await input.question('Chọn số (1/2/3): ')).trim();
60
- if (!['1', '2', '3'].includes(answer)) throw new Error('Chọn 1, 2 hoặc 3.');
61
- return answer;
99
+ return answer.trim();
62
100
  }
63
101
 
64
102
  async function chooseModel(input, currentModel) {
65
- console.log('\nModel mặc định cho Claude Code:');
66
- VIROUTER_MODELS.forEach((model, index) => console.log(` ${index + 1} = ${model}`));
103
+ console.log(`\n${style.bold('Model mặc định cho Claude Code')} ${style.dim('(đổi lại bất cứ lúc nào bằng /model)')}`);
67
104
  const defaultIndex = Math.max(0, VIROUTER_MODELS.indexOf(currentModel));
68
- const answer = (await input.question(`Chọn số [${defaultIndex + 1}]: `)).trim() || String(defaultIndex + 1);
105
+ VIROUTER_MODELS.forEach((model, index) => {
106
+ const isDefault = index === defaultIndex;
107
+ const number = style.cyan(String(index + 1).padStart(2));
108
+ console.log(` ${number} ${isDefault ? style.bold(model) : model}${isDefault ? style.green(' ← mặc định') : ''}`);
109
+ });
110
+ const answer = (await input.question(`Chọn số ${style.dim(`[${defaultIndex + 1}]`)}: `)).trim() || String(defaultIndex + 1);
69
111
  const selected = VIROUTER_MODELS[Number(answer) - 1];
70
112
  if (!selected) throw new Error('Lựa chọn model không hợp lệ.');
71
113
  return selected;
@@ -75,8 +117,8 @@ function existingConfig() {
75
117
  const candidates = process.platform === 'win32'
76
118
  ? []
77
119
  : process.platform === 'darwin'
78
- ? [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]
79
- : [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')];
120
+ ? profilePaths('2')
121
+ : profilePaths('3');
80
122
  const sources = candidates
81
123
  .filter((path) => existsSync(path))
82
124
  .map((path) => readFileSync(path, 'utf8'));
@@ -125,21 +167,134 @@ export function resolveStartingModel(settingsModel, legacyModel) {
125
167
  return null;
126
168
  }
127
169
 
128
- /** Returns new settings with `model` set and any ANTHROPIC_MODEL pin removed from `env`. */
170
+ /**
171
+ * Returns new settings with `model` set, any ANTHROPIC_MODEL pin removed from `env`, and the
172
+ * picker's "Default" row pointed at VIROUTER's default.
173
+ *
174
+ * `/model` always lists a "Default (recommended)" row that resolves to Claude Code's own
175
+ * runtime default — a model name this gateway may not serve. ANTHROPIC_DEFAULT_MODEL makes
176
+ * that row resolve to ours instead ("Set by ANTHROPIC_DEFAULT_MODEL"), and unlike
177
+ * ANTHROPIC_MODEL it does not override a choice the customer saved with /model.
178
+ */
129
179
  export function withRememberedModel(settings, model) {
130
- const next = { ...settings, model };
131
- if (next.env && typeof next.env === 'object' && 'ANTHROPIC_MODEL' in next.env) {
132
- const { ANTHROPIC_MODEL: _pinned, ...env } = next.env;
133
- next.env = env;
134
- }
135
- return next;
180
+ const currentEnv = settings.env && typeof settings.env === 'object' ? settings.env : {};
181
+ const { ANTHROPIC_MODEL: _pinned, ...env } = currentEnv;
182
+ if (!VIROUTER_MODELS.includes(env.ANTHROPIC_DEFAULT_MODEL)) env.ANTHROPIC_DEFAULT_MODEL = DEFAULT_MODEL;
183
+ return { ...settings, model, env };
184
+ }
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());
136
220
  }
137
221
 
138
222
  function writeClaudeSettings(path, settings) {
139
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
+ }
140
229
  writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
141
230
  }
142
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
+
143
298
  function detectAgentToolkit() {
144
299
  const candidates = [
145
300
  join(process.cwd(), '.claude', '.ck.json'),
@@ -199,6 +354,7 @@ function configLines(apiKey, pinnedModel) {
199
354
  // traffic must stay enabled or discovery is skipped.
200
355
  'export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY="1"',
201
356
  'unset CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
357
+ `unset ${CLOUD_PROVIDER_SWITCHES.join(' ')}`,
202
358
  ];
203
359
  }
204
360
 
@@ -211,16 +367,19 @@ function updateProfile(path, lines) {
211
367
  return path;
212
368
  }
213
369
 
214
- function configureWindows(apiKey, pinnedModel) {
215
- if (!pinnedModel) {
216
- // An empty `setx` value is still a variable; delete the old pin outright so `/model` in
217
- // ~/.claude/settings.json decides. Absent already is fine.
218
- try {
219
- execFileSync('reg', ['delete', 'HKCU\\Environment', '/v', 'ANTHROPIC_MODEL', '/f'], { stdio: 'ignore' });
220
- } catch {
221
- // Nothing to delete.
222
- }
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.
223
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);
224
383
  const values = {
225
384
  ANTHROPIC_BASE_URL: API_ORIGIN,
226
385
  ANTHROPIC_AUTH_TOKEN: apiKey,
@@ -291,16 +450,172 @@ function configureOpenCode(apiKey, model) {
291
450
  return path;
292
451
  }
293
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
+
294
463
  function configureUnix(os, apiKey, pinnedModel) {
295
464
  const lines = configLines(apiKey, pinnedModel);
296
- const profiles = os === '2'
297
- ? [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]
298
- : [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')];
299
-
465
+ const profiles = profilePaths(os);
300
466
  for (const profile of profiles) updateProfile(profile, lines);
301
- 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
+ }
302
483
  }
303
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
+ }
616
+ }
617
+
618
+ /** `{ ok: true, me }`, `{ ok: false }` when the gateway rejects the key, or null if unreachable. */
304
619
  async function validateKey(apiKey) {
305
620
  const controller = new AbortController();
306
621
  const timer = setTimeout(() => controller.abort(), 5000);
@@ -309,7 +624,8 @@ async function validateKey(apiKey) {
309
624
  headers: { authorization: `Bearer ${apiKey}` },
310
625
  signal: controller.signal,
311
626
  });
312
- return response.ok;
627
+ if (!response.ok) return { ok: false };
628
+ return { ok: true, me: await response.json().catch(() => null) };
313
629
  } catch {
314
630
  return null;
315
631
  } finally {
@@ -317,6 +633,19 @@ async function validateKey(apiKey) {
317
633
  }
318
634
  }
319
635
 
636
+ function describeKey(me) {
637
+ if (!me || typeof me !== 'object') return '';
638
+ const parts = [];
639
+ if (me.planCode) parts.push(`gói ${me.planCode}`);
640
+ if (typeof me.creditsRemaining === 'number') {
641
+ parts.push(`còn ${me.creditsRemaining.toLocaleString('vi-VN')} credit`);
642
+ }
643
+ if (typeof me.expiresAt === 'number') {
644
+ parts.push(`hết hạn ${new Date(me.expiresAt * 1000).toLocaleDateString('vi-VN')}`);
645
+ }
646
+ return parts.join(' · ');
647
+ }
648
+
320
649
  function launchClaude(apiKey, pinnedModel) {
321
650
  const env = {
322
651
  ...process.env,
@@ -326,6 +655,7 @@ function launchClaude(apiKey, pinnedModel) {
326
655
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
327
656
  };
328
657
  delete env.ANTHROPIC_API_KEY;
658
+ for (const name of CLOUD_PROVIDER_SWITCHES) delete env[name];
329
659
  // Inherited from an older install's shell, this would override settings.json for this run.
330
660
  if (pinnedModel) env.ANTHROPIC_MODEL = pinnedModel;
331
661
  else delete env.ANTHROPIC_MODEL;
@@ -345,12 +675,18 @@ async function main() {
345
675
  printHelp();
346
676
  return;
347
677
  }
678
+ if (process.argv.includes('--doctor')) {
679
+ await doctor();
680
+ return;
681
+ }
348
682
 
349
- console.log(`\nVIROUTER Claude Code installer\nTarget: ${API_ORIGIN}\n`);
683
+ const machine = detectMachine();
684
+ console.log(`\n${style.bold(style.cyan('VIROUTER'))} ${style.bold('· Claude Code installer')}`);
685
+ console.log(style.dim(`Gateway ${API_ORIGIN}`));
686
+ console.log(style.dim(`Máy ${machine.name} ${release()} · ${arch()} · ${hostname()}`));
687
+ console.log('');
350
688
  const agentToolkit = detectAgentToolkit();
351
- if (agentToolkit) {
352
- console.log(`Phát hiện ${agentToolkit}. Giữ nguyên skills, rules và hooks hiện có.`);
353
- }
689
+ if (agentToolkit) step(`Phát hiện ${agentToolkit}`, '— giữ nguyên skills, rules và hooks hiện có');
354
690
  let input;
355
691
 
356
692
  try {
@@ -358,13 +694,20 @@ async function main() {
358
694
  const reuseEnvironmentKey = process.argv.includes('--reuse-key') || !process.stdin.isTTY;
359
695
  const apiKey = reuseEnvironmentKey && process.env.VIROUTER_API_KEY
360
696
  ? process.env.VIROUTER_API_KEY
361
- : await readVisibleInput('Auth Token / API Key (sẽ hiển thị khi nhập): ');
697
+ : await readVisibleInput(`${style.bold('API key')} ${style.dim('(viro_…, hiển thị khi nhập)')}: `);
362
698
  if (!/^viro_[A-Za-z0-9_-]{20,}$/.test(apiKey)) {
363
699
  throw new Error('API key không hợp lệ. Key phải bắt đầu bằng viro_.');
364
700
  }
365
701
 
702
+ const checked = await validateKey(apiKey);
703
+ if (checked && !checked.ok) {
704
+ throw new Error(`API key bị từ chối bởi ${API_ORIGIN}. Kiểm tra key hoặc VIROUTER_API_ORIGIN.`);
705
+ }
706
+ if (checked) step(`Key ${style.bold(maskKey(apiKey))} hợp lệ`, describeKey(checked.me));
707
+ else console.log(`${style.yellow('!')} Key ${maskKey(apiKey)} — không kiểm tra được lúc này, vẫn lưu cấu hình.`);
708
+
366
709
  input = createInterface({ input: process.stdin, output: process.stdout });
367
- const os = await chooseOs(input);
710
+ const { os } = machine;
368
711
  const keyChanged = previous.key !== null && previous.key !== apiKey;
369
712
  const claudeSettings = readClaudeSettings();
370
713
  // Re-running the installer keeps whatever the customer last picked with /model.
@@ -374,44 +717,64 @@ async function main() {
374
717
  : await chooseModel(input, remembered ?? DEFAULT_MODEL);
375
718
  input.close();
376
719
  input = undefined;
377
-
378
- const valid = await validateKey(apiKey);
379
- if (valid === false) {
380
- throw new Error(`API key bị từ chối bởi ${API_ORIGIN}. Kiểm tra key hoặc VIROUTER_API_ORIGIN.`);
381
- }
382
- if (valid === null) console.log('Không kiểm tra được API lúc này. Vẫn tiếp tục lưu cấu hình.');
720
+ console.log('');
383
721
 
384
722
  // Remember the model in settings.json; pin it in the environment only if that file is not
385
723
  // ours to rewrite (unparseable), since a session with no model at all would be worse.
386
724
  let pinnedModel = null;
725
+ let settingsEnv = {};
387
726
  if (claudeSettings.writable) {
388
- 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(', '));
389
733
  } else {
390
734
  pinnedModel = model;
391
- console.log(`Không đọc được ${claudeSettings.path} (không phải JSON hợp lệ).`);
392
- console.log('Model sẽ cố định qua biến môi trường; /model sẽ không được ghi nhớ giữa các phiên.');
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.'));
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.'));
393
738
  }
394
739
 
395
740
  const location = os === '1'
396
741
  ? configureWindows(apiKey, pinnedModel)
397
742
  : configureUnix(os, apiKey, pinnedModel);
398
743
  const openCodeConfig = configureOpenCode(apiKey, model);
399
- console.log(`\nĐã cấu hình Claude Code trong ${location}.`);
400
- console.log(`Model: ${model}. Đổi bằng /model trong Claude Code — lần mở sau sẽ giữ model đã chọn.`);
401
- if (openCodeConfig) console.log(`Đã cấu hình OpenCode chỉ dùng alias VIROUTER trong ${openCodeConfig}.`);
744
+ step('Claude Code', location);
745
+ step(`Model ${style.bold(model)}`, '— đổi bằng /model, lần mở sau giữ model đã chọn');
746
+ if (openCodeConfig) step('OpenCode', openCodeConfig);
747
+ reportEditors(configureEditors());
748
+ await reportOverrides(settingsEnv);
402
749
  if (process.argv.includes('--no-launch')) {
403
- console.log('Chạy `claude` trong terminal mới để dùng cấu hình vừa tạo.\n');
750
+ console.log(`\nMở terminal mới rồi chạy ${style.cyan('claude')}.\n`);
404
751
  } else {
405
- console.log('\nKhởi chạy Claude Code với VIROUTER...\n');
752
+ console.log(`\n${style.cyan('→')} Khởi chạy Claude Code với VIROUTER…\n`);
406
753
  launchClaude(apiKey, pinnedModel);
407
754
  }
408
755
  } catch (error) {
409
756
  input?.close();
410
- console.error(`\nLỗi: ${error instanceof Error ? error.message : String(error)}`);
757
+ console.error(`\n${style.red('Lỗi:')} ${error instanceof Error ? error.message : String(error)}`);
411
758
  process.exitCode = 1;
412
759
  }
413
760
  }
414
761
 
415
- if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
762
+ /**
763
+ * True when this file is the program being run, not a module imported by a test.
764
+ *
765
+ * `npx` runs the package through a symlink (`node_modules/.bin/virouter`). Node reports that
766
+ * link in `argv[1]` but loads the real file, so comparing the two raw paths never matched on
767
+ * macOS and Linux: `npx virouter@latest` installed, printed nothing and exited.
768
+ */
769
+ export function isMainModule(argv1 = process.argv[1], moduleUrl = import.meta.url) {
770
+ if (!argv1) return false;
771
+ try {
772
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
773
+ } catch {
774
+ return false;
775
+ }
776
+ }
777
+
778
+ if (isMainModule()) {
416
779
  await main();
417
780
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virouter",
3
- "version": "0.2.4",
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",