virouter 0.2.4 → 0.2.5

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 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,8 +42,8 @@ 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
package/bin/virouter.mjs CHANGED
@@ -1,11 +1,11 @@
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 { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
5
+ import { arch, homedir, hostname, release } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { createInterface } from 'node:readline/promises';
8
- import { pathToFileURL } from 'node:url';
8
+ import { fileURLToPath } from 'node:url';
9
9
 
10
10
  const API_ORIGIN = (process.env.VIROUTER_API_ORIGIN || 'https://virouter.click').replace(/\/$/, '');
11
11
  const MARKER_START = '# >>> VIROUTER CLAUDE CODE CONFIG >>>';
@@ -42,30 +42,59 @@ For local testing, set VIROUTER_API_ORIGIN=http://127.0.0.1:8787.
42
42
  It does not install a server or store a local model.`);
43
43
  }
44
44
 
45
+ // Colour only on a real terminal, and never when NO_COLOR is set (https://no-color.org).
46
+ const USE_COLOR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
47
+ const paint = (code) => (text) => (USE_COLOR ? `\x1b[${code}m${text}\x1b[0m` : String(text));
48
+ const style = {
49
+ bold: paint('1'),
50
+ dim: paint('2'),
51
+ red: paint('31'),
52
+ green: paint('32'),
53
+ yellow: paint('33'),
54
+ cyan: paint('36'),
55
+ };
56
+
57
+ /** Enough of the key to recognise it, never enough to use it. */
58
+ export function maskKey(key) {
59
+ return key.length > 16 ? `${key.slice(0, 9)}…${key.slice(-4)}` : `${key.slice(0, 5)}…`;
60
+ }
61
+
62
+ /**
63
+ * The machine the installer is configuring. Detected rather than asked: picking the wrong
64
+ * number wrote a shell profile on Windows or `setx` on Linux, and neither failed loudly.
65
+ */
66
+ export function detectMachine(platform = process.platform) {
67
+ if (platform === 'win32') return { os: '1', name: 'Windows' };
68
+ if (platform === 'darwin') return { os: '2', name: 'macOS' };
69
+ let wsl = false;
70
+ try {
71
+ wsl = /microsoft/i.test(readFileSync('/proc/version', 'utf8'));
72
+ } catch {
73
+ // Not Linux proper, or /proc is unavailable; plain Linux it is.
74
+ }
75
+ return { os: '3', name: wsl ? 'Linux (WSL)' : 'Linux' };
76
+ }
77
+
78
+ function step(label, detail = '') {
79
+ console.log(`${style.green('✓')} ${label}${detail ? ` ${style.dim(detail)}` : ''}`);
80
+ }
81
+
45
82
  async function readVisibleInput(prompt) {
46
83
  const line = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
47
84
  const answer = await line.question(prompt);
48
85
  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;
86
+ return answer.trim();
62
87
  }
63
88
 
64
89
  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}`));
90
+ 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
91
  const defaultIndex = Math.max(0, VIROUTER_MODELS.indexOf(currentModel));
68
- const answer = (await input.question(`Chọn số [${defaultIndex + 1}]: `)).trim() || String(defaultIndex + 1);
92
+ VIROUTER_MODELS.forEach((model, index) => {
93
+ const isDefault = index === defaultIndex;
94
+ const number = style.cyan(String(index + 1).padStart(2));
95
+ console.log(` ${number} ${isDefault ? style.bold(model) : model}${isDefault ? style.green(' ← mặc định') : ''}`);
96
+ });
97
+ const answer = (await input.question(`Chọn số ${style.dim(`[${defaultIndex + 1}]`)}: `)).trim() || String(defaultIndex + 1);
69
98
  const selected = VIROUTER_MODELS[Number(answer) - 1];
70
99
  if (!selected) throw new Error('Lựa chọn model không hợp lệ.');
71
100
  return selected;
@@ -125,14 +154,20 @@ export function resolveStartingModel(settingsModel, legacyModel) {
125
154
  return null;
126
155
  }
127
156
 
128
- /** Returns new settings with `model` set and any ANTHROPIC_MODEL pin removed from `env`. */
157
+ /**
158
+ * Returns new settings with `model` set, any ANTHROPIC_MODEL pin removed from `env`, and the
159
+ * picker's "Default" row pointed at VIROUTER's default.
160
+ *
161
+ * `/model` always lists a "Default (recommended)" row that resolves to Claude Code's own
162
+ * runtime default — a model name this gateway may not serve. ANTHROPIC_DEFAULT_MODEL makes
163
+ * that row resolve to ours instead ("Set by ANTHROPIC_DEFAULT_MODEL"), and unlike
164
+ * ANTHROPIC_MODEL it does not override a choice the customer saved with /model.
165
+ */
129
166
  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;
167
+ const currentEnv = settings.env && typeof settings.env === 'object' ? settings.env : {};
168
+ const { ANTHROPIC_MODEL: _pinned, ...env } = currentEnv;
169
+ if (!VIROUTER_MODELS.includes(env.ANTHROPIC_DEFAULT_MODEL)) env.ANTHROPIC_DEFAULT_MODEL = DEFAULT_MODEL;
170
+ return { ...settings, model, env };
136
171
  }
137
172
 
138
173
  function writeClaudeSettings(path, settings) {
@@ -301,6 +336,7 @@ function configureUnix(os, apiKey, pinnedModel) {
301
336
  return profiles.join(' and ');
302
337
  }
303
338
 
339
+ /** `{ ok: true, me }`, `{ ok: false }` when the gateway rejects the key, or null if unreachable. */
304
340
  async function validateKey(apiKey) {
305
341
  const controller = new AbortController();
306
342
  const timer = setTimeout(() => controller.abort(), 5000);
@@ -309,7 +345,8 @@ async function validateKey(apiKey) {
309
345
  headers: { authorization: `Bearer ${apiKey}` },
310
346
  signal: controller.signal,
311
347
  });
312
- return response.ok;
348
+ if (!response.ok) return { ok: false };
349
+ return { ok: true, me: await response.json().catch(() => null) };
313
350
  } catch {
314
351
  return null;
315
352
  } finally {
@@ -317,6 +354,19 @@ async function validateKey(apiKey) {
317
354
  }
318
355
  }
319
356
 
357
+ function describeKey(me) {
358
+ if (!me || typeof me !== 'object') return '';
359
+ const parts = [];
360
+ if (me.planCode) parts.push(`gói ${me.planCode}`);
361
+ if (typeof me.creditsRemaining === 'number') {
362
+ parts.push(`còn ${me.creditsRemaining.toLocaleString('vi-VN')} credit`);
363
+ }
364
+ if (typeof me.expiresAt === 'number') {
365
+ parts.push(`hết hạn ${new Date(me.expiresAt * 1000).toLocaleDateString('vi-VN')}`);
366
+ }
367
+ return parts.join(' · ');
368
+ }
369
+
320
370
  function launchClaude(apiKey, pinnedModel) {
321
371
  const env = {
322
372
  ...process.env,
@@ -346,11 +396,13 @@ async function main() {
346
396
  return;
347
397
  }
348
398
 
349
- console.log(`\nVIROUTER Claude Code installer\nTarget: ${API_ORIGIN}\n`);
399
+ const machine = detectMachine();
400
+ console.log(`\n${style.bold(style.cyan('VIROUTER'))} ${style.bold('· Claude Code installer')}`);
401
+ console.log(style.dim(`Gateway ${API_ORIGIN}`));
402
+ console.log(style.dim(`Máy ${machine.name} ${release()} · ${arch()} · ${hostname()}`));
403
+ console.log('');
350
404
  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
- }
405
+ if (agentToolkit) step(`Phát hiện ${agentToolkit}`, '— giữ nguyên skills, rules và hooks hiện có');
354
406
  let input;
355
407
 
356
408
  try {
@@ -358,13 +410,20 @@ async function main() {
358
410
  const reuseEnvironmentKey = process.argv.includes('--reuse-key') || !process.stdin.isTTY;
359
411
  const apiKey = reuseEnvironmentKey && process.env.VIROUTER_API_KEY
360
412
  ? process.env.VIROUTER_API_KEY
361
- : await readVisibleInput('Auth Token / API Key (sẽ hiển thị khi nhập): ');
413
+ : await readVisibleInput(`${style.bold('API key')} ${style.dim('(viro_…, hiển thị khi nhập)')}: `);
362
414
  if (!/^viro_[A-Za-z0-9_-]{20,}$/.test(apiKey)) {
363
415
  throw new Error('API key không hợp lệ. Key phải bắt đầu bằng viro_.');
364
416
  }
365
417
 
418
+ const checked = await validateKey(apiKey);
419
+ if (checked && !checked.ok) {
420
+ throw new Error(`API key bị từ chối bởi ${API_ORIGIN}. Kiểm tra key hoặc VIROUTER_API_ORIGIN.`);
421
+ }
422
+ if (checked) step(`Key ${style.bold(maskKey(apiKey))} hợp lệ`, describeKey(checked.me));
423
+ 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.`);
424
+
366
425
  input = createInterface({ input: process.stdin, output: process.stdout });
367
- const os = await chooseOs(input);
426
+ const { os } = machine;
368
427
  const keyChanged = previous.key !== null && previous.key !== apiKey;
369
428
  const claudeSettings = readClaudeSettings();
370
429
  // Re-running the installer keeps whatever the customer last picked with /model.
@@ -374,12 +433,7 @@ async function main() {
374
433
  : await chooseModel(input, remembered ?? DEFAULT_MODEL);
375
434
  input.close();
376
435
  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.');
436
+ console.log('');
383
437
 
384
438
  // Remember the model in settings.json; pin it in the environment only if that file is not
385
439
  // ours to rewrite (unparseable), since a session with no model at all would be worse.
@@ -388,30 +442,46 @@ async function main() {
388
442
  writeClaudeSettings(claudeSettings.path, withRememberedModel(claudeSettings.settings, model));
389
443
  } else {
390
444
  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.');
445
+ console.log(`${style.yellow('!')} Không đọc được ${claudeSettings.path} (không phải JSON hợp lệ).`);
446
+ 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
447
  }
394
448
 
395
449
  const location = os === '1'
396
450
  ? configureWindows(apiKey, pinnedModel)
397
451
  : configureUnix(os, apiKey, pinnedModel);
398
452
  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}.`);
453
+ step('Claude Code', location);
454
+ step(`Model ${style.bold(model)}`, '— đổi bằng /model, lần mở sau giữ model đã chọn');
455
+ if (openCodeConfig) step('OpenCode', openCodeConfig);
402
456
  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');
457
+ console.log(`\nMở terminal mới rồi chạy ${style.cyan('claude')}.\n`);
404
458
  } else {
405
- console.log('\nKhởi chạy Claude Code với VIROUTER...\n');
459
+ console.log(`\n${style.cyan('→')} Khởi chạy Claude Code với VIROUTER…\n`);
406
460
  launchClaude(apiKey, pinnedModel);
407
461
  }
408
462
  } catch (error) {
409
463
  input?.close();
410
- console.error(`\nLỗi: ${error instanceof Error ? error.message : String(error)}`);
464
+ console.error(`\n${style.red('Lỗi:')} ${error instanceof Error ? error.message : String(error)}`);
411
465
  process.exitCode = 1;
412
466
  }
413
467
  }
414
468
 
415
- if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
469
+ /**
470
+ * True when this file is the program being run, not a module imported by a test.
471
+ *
472
+ * `npx` runs the package through a symlink (`node_modules/.bin/virouter`). Node reports that
473
+ * link in `argv[1]` but loads the real file, so comparing the two raw paths never matched on
474
+ * macOS and Linux: `npx virouter@latest` installed, printed nothing and exited.
475
+ */
476
+ export function isMainModule(argv1 = process.argv[1], moduleUrl = import.meta.url) {
477
+ if (!argv1) return false;
478
+ try {
479
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
485
+ if (isMainModule()) {
416
486
  await main();
417
487
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virouter",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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",