virouter 0.2.3 → 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.
Files changed (3) hide show
  1. package/README.md +38 -3
  2. package/bin/virouter.mjs +356 -68
  3. package/package.json +6 -14
package/README.md CHANGED
@@ -1,11 +1,15 @@
1
1
  # virouter
2
2
 
3
- Interactive installer for Claude Code on Windows, macOS and Linux.
3
+ Interactive installer for Claude Code and OpenCode on Windows, macOS and Linux.
4
4
 
5
5
  ```bash
6
6
  npx virouter@latest
7
7
  ```
8
8
 
9
+ The installer launches Claude Code with the correct VIROUTER environment immediately. Use
10
+ `--no-launch` when you only want to write configuration files. Use `--reuse-key` to deliberately
11
+ reuse `VIROUTER_API_KEY` without prompting.
12
+
9
13
  For a local Worker, override the origin before running the installer:
10
14
 
11
15
  ```powershell
@@ -13,12 +17,43 @@ $env:VIROUTER_API_ORIGIN="http://127.0.0.1:8787"
13
17
  npx virouter@latest
14
18
  ```
15
19
 
16
- The installer asks for a `viro_...` API key and configures:
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:
17
23
 
18
24
  - `ANTHROPIC_BASE_URL=https://virouter.click`
19
25
  - `ANTHROPIC_AUTH_TOKEN`
20
- - `ANTHROPIC_MODEL=claude-sonnet-5`
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)
21
29
  - `ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5`
30
+ - `VIROUTER_API_KEY` for OpenCode
31
+ - `~/.config/opencode/opencode.json` with VIROUTER as the default provider
32
+
33
+ Interactive runs always ask for the key again and overwrite local configuration with the key you
34
+ paste. The previous key remains active on the server until you revoke it separately. Running with
35
+ the same key keeps the existing model; a new key asks for the model again.
36
+
37
+ The model is remembered, not pinned. It is written to `model` in `~/.claude/settings.json`, which is
38
+ where Claude Code's `/model` saves a new choice, so every new `claude` session starts on the model
39
+ used last. `ANTHROPIC_MODEL` is deliberately removed (`unset` in the shell profile, deleted from
40
+ the Windows user environment): it applies over the settings file and would send every session
41
+ back to the install-time model. An install that pinned it is migrated — that model moves into
42
+ `settings.json`. Only if `settings.json` is not valid JSON does the installer leave it untouched
43
+ and fall back to pinning `ANTHROPIC_MODEL`.
44
+
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.
47
+
48
+ When an OpenCode configuration already exists, its enabled providers and credentials are
49
+ preserved; VIROUTER is appended and selected as the default provider. A new configuration
50
+ enables only VIROUTER. This allows existing ClaudeKit/ECC/OpenCode workflows to continue
51
+ without losing their provider setup.
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.
22
57
 
23
58
  The customer key is never stored in this package or repository. Obtain it from the one-time
24
59
  claim/recovery flow documented in `docs/claude-code-setup.md`; do not use an upstream
package/bin/virouter.mjs CHANGED
@@ -1,76 +1,210 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from 'node:child_process';
4
- import { existsSync, 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 { fileURLToPath } from 'node:url';
8
9
 
9
10
  const API_ORIGIN = (process.env.VIROUTER_API_ORIGIN || 'https://virouter.click').replace(/\/$/, '');
10
11
  const MARKER_START = '# >>> VIROUTER CLAUDE CODE CONFIG >>>';
11
12
  const MARKER_END = '# <<< VIROUTER CLAUDE CODE CONFIG <<<';
12
-
13
- function detectedOs() {
14
- if (process.platform === 'win32') return '1';
15
- if (process.platform === 'darwin') return '2';
16
- return '3';
17
- }
13
+ const OPENCODE_PROVIDER_ID = 'virouter';
14
+ /** What a first install starts on. After that the customer's own `/model` choice wins. */
15
+ const DEFAULT_MODEL = 'claude-fable-5-1';
16
+ const VIROUTER_MODELS = [
17
+ 'claude-sonnet-5',
18
+ 'claude-fable-5-1',
19
+ 'claude-opus-5-5',
20
+ 'claude-opus-5',
21
+ 'claude-haiku-4-5',
22
+ 'claude-fable-5',
23
+ 'claude-opus-4-8',
24
+ 'claude-opus-4-7',
25
+ 'claude-opus-4-6',
26
+ 'claude-sonnet-4-6',
27
+ 'claude-haiku-4-5-lite',
28
+ 'claude-haiku-4-5-alt',
29
+ ];
18
30
 
19
31
  function printHelp() {
20
- console.log(`VIROUTER Claude Code installer
32
+ console.log(`VIROUTER Claude Code + OpenCode installer
21
33
 
22
34
  Usage:
23
35
  npx virouter@latest
24
36
  npx virouter@latest --help
37
+ npx virouter@latest --no-launch
38
+ npx virouter@latest --reuse-key
25
39
 
26
- The installer configures Claude Code to use ${API_ORIGIN}.
40
+ The installer configures Claude Code and OpenCode to use ${API_ORIGIN}.
27
41
  For local testing, set VIROUTER_API_ORIGIN=http://127.0.0.1:8787.
28
- It does not install a server or store a local model.`);
42
+ It does not install a server or store a local model.`);
43
+ }
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)}…`;
29
60
  }
30
61
 
31
- async function readSecret(prompt) {
32
- if (!process.stdin.isTTY || !process.stdin.setRawMode) {
33
- const line = createInterface({ input: process.stdin, output: process.stdout });
34
- const answer = await line.question(prompt);
35
- line.close();
36
- return answer.trim();
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.
37
74
  }
75
+ return { os: '3', name: wsl ? 'Linux (WSL)' : 'Linux' };
76
+ }
38
77
 
39
- return new Promise((resolve, reject) => {
40
- let value = '';
41
- const onData = (chunk) => {
42
- const text = chunk.toString();
43
- for (const char of text) {
44
- if (char === '\u0003') {
45
- process.stdin.setRawMode(false);
46
- process.stdin.pause();
47
- reject(new Error('Cancelled.'));
48
- return;
49
- }
50
- if (char === '\r' || char === '\n') {
51
- process.stdin.setRawMode(false);
52
- process.stdin.pause();
53
- process.stdin.off('data', onData);
54
- process.stdout.write('\n');
55
- resolve(value.trim());
56
- return;
57
- }
58
- if (char === '\u007f') value = value.slice(0, -1);
59
- else value += char;
60
- }
61
- };
78
+ function step(label, detail = '') {
79
+ console.log(`${style.green('✓')} ${label}${detail ? ` ${style.dim(detail)}` : ''}`);
80
+ }
81
+
82
+ async function readVisibleInput(prompt) {
83
+ const line = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
84
+ const answer = await line.question(prompt);
85
+ line.close();
86
+ return answer.trim();
87
+ }
62
88
 
63
- process.stdout.write(prompt);
64
- process.stdin.setRawMode(true);
65
- process.stdin.resume();
66
- process.stdin.on('data', onData);
89
+ async function chooseModel(input, currentModel) {
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)')}`);
91
+ const defaultIndex = Math.max(0, VIROUTER_MODELS.indexOf(currentModel));
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') : ''}`);
67
96
  });
97
+ const answer = (await input.question(`Chọn số ${style.dim(`[${defaultIndex + 1}]`)}: `)).trim() || String(defaultIndex + 1);
98
+ const selected = VIROUTER_MODELS[Number(answer) - 1];
99
+ if (!selected) throw new Error('Lựa chọn model không hợp lệ.');
100
+ return selected;
101
+ }
102
+
103
+ function existingConfig() {
104
+ const candidates = process.platform === 'win32'
105
+ ? []
106
+ : process.platform === 'darwin'
107
+ ? [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]
108
+ : [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')];
109
+ const sources = candidates
110
+ .filter((path) => existsSync(path))
111
+ .map((path) => readFileSync(path, 'utf8'));
112
+ const combined = sources.join('\n');
113
+ const key = combined.match(/export VIROUTER_API_KEY="([^"]+)"/)?.[1]
114
+ || process.env.VIROUTER_API_KEY
115
+ || null;
116
+ const model = combined.match(/export ANTHROPIC_MODEL="([^"]+)"/)?.[1]
117
+ || process.env.ANTHROPIC_MODEL
118
+ || null;
119
+ return { key, model: VIROUTER_MODELS.includes(model) ? model : null };
120
+ }
121
+
122
+ /**
123
+ * Claude Code's user settings. `/model` writes the customer's pick to `model` here and every
124
+ * new session starts on it — unless `ANTHROPIC_MODEL` is set, which applies over the `model`
125
+ * key from any file. Earlier installers exported ANTHROPIC_MODEL, so every new `claude` went
126
+ * back to the install-time model whatever the customer had switched to. The model now lives
127
+ * only here, where `/model` can change it.
128
+ */
129
+ function claudeSettingsPath() {
130
+ return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
131
+ }
132
+
133
+ function readClaudeSettings() {
134
+ const path = claudeSettingsPath();
135
+ if (!existsSync(path)) return { path, settings: {}, writable: true };
136
+ try {
137
+ const settings = JSON.parse(readFileSync(path, 'utf8'));
138
+ return settings && typeof settings === 'object' && !Array.isArray(settings)
139
+ ? { path, settings, writable: true }
140
+ : { path, settings: {}, writable: false };
141
+ } catch {
142
+ // Never overwrite a file we could not parse; the caller falls back to the env variable.
143
+ return { path, settings: {}, writable: false };
144
+ }
68
145
  }
69
146
 
70
- async function chooseOs(input) {
71
- const answer = (await input.question(`Hệ điều hành [${detectedOs()}]: `)).trim() || detectedOs();
72
- if (!['1', '2', '3'].includes(answer)) throw new Error('Chọn 1, 2 hoặc 3.');
73
- return answer;
147
+ /**
148
+ * The model a (re)install should keep: the customer's last `/model` choice, else the one an
149
+ * older installer pinned in ANTHROPIC_MODEL, else nothing (first install → DEFAULT_MODEL).
150
+ */
151
+ export function resolveStartingModel(settingsModel, legacyModel) {
152
+ if (VIROUTER_MODELS.includes(settingsModel)) return settingsModel;
153
+ if (VIROUTER_MODELS.includes(legacyModel)) return legacyModel;
154
+ return null;
155
+ }
156
+
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
+ */
166
+ export function withRememberedModel(settings, model) {
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 };
171
+ }
172
+
173
+ function writeClaudeSettings(path, settings) {
174
+ mkdirSync(join(path, '..'), { recursive: true });
175
+ writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
176
+ }
177
+
178
+ function detectAgentToolkit() {
179
+ const candidates = [
180
+ join(process.cwd(), '.claude', '.ck.json'),
181
+ join(process.cwd(), '.claude', 'metadata.json'),
182
+ join(homedir(), '.claude', '.ck.json'),
183
+ join(homedir(), '.claude', 'metadata.json'),
184
+ ];
185
+
186
+ for (const path of candidates) {
187
+ if (!existsSync(path)) continue;
188
+ try {
189
+ const config = JSON.parse(readFileSync(path, 'utf8'));
190
+ if (config.kits?.engineer || config.kits?.['ClaudeKit Engineer'] || config.plan) {
191
+ return 'ClaudeKit';
192
+ }
193
+ } catch {
194
+ // A malformed optional toolkit file must not block gateway installation.
195
+ }
196
+ }
197
+
198
+ const eccManifest = join(process.cwd(), '.claude', 'plugin.json');
199
+ if (existsSync(eccManifest)) {
200
+ try {
201
+ if (JSON.parse(readFileSync(eccManifest, 'utf8')).name === 'ecc') return 'ECC';
202
+ } catch {
203
+ // Keep installation independent from optional local agent configuration.
204
+ }
205
+ }
206
+
207
+ return null;
74
208
  }
75
209
 
76
210
  /**
@@ -85,12 +219,16 @@ async function chooseOs(input) {
85
219
  * The unset matters: an ANTHROPIC_API_KEY left over from an earlier install keeps
86
220
  * shadowing the token, so the fix has to remove it rather than just add the right variable.
87
221
  */
88
- function configLines(apiKey) {
222
+ function configLines(apiKey, pinnedModel) {
89
223
  return [
224
+ 'export PATH="$HOME/.local/bin:$PATH"',
90
225
  'unset ANTHROPIC_API_KEY',
91
226
  `export ANTHROPIC_BASE_URL="${API_ORIGIN}"`,
92
227
  `export ANTHROPIC_AUTH_TOKEN="${apiKey}"`,
93
- 'export ANTHROPIC_MODEL="claude-sonnet-5"',
228
+ `export VIROUTER_API_KEY="${apiKey}"`,
229
+ // The model lives in ~/.claude/settings.json so `/model` is remembered. Only when that
230
+ // file could not be written is it pinned here instead.
231
+ pinnedModel ? `export ANTHROPIC_MODEL="${pinnedModel}"` : 'unset ANTHROPIC_MODEL',
94
232
  'export ANTHROPIC_SMALL_FAST_MODEL="claude-haiku-4-5"',
95
233
  // Lets Claude Code's /model picker list our models from /v1/models. Nonessential
96
234
  // traffic must stay enabled or discovery is skipped.
@@ -108,11 +246,21 @@ function updateProfile(path, lines) {
108
246
  return path;
109
247
  }
110
248
 
111
- function configureWindows(apiKey) {
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
+ }
258
+ }
112
259
  const values = {
113
260
  ANTHROPIC_BASE_URL: API_ORIGIN,
114
261
  ANTHROPIC_AUTH_TOKEN: apiKey,
115
- ANTHROPIC_MODEL: 'claude-sonnet-5',
262
+ VIROUTER_API_KEY: apiKey,
263
+ ...(pinnedModel ? { ANTHROPIC_MODEL: pinnedModel } : {}),
116
264
  ANTHROPIC_SMALL_FAST_MODEL: 'claude-haiku-4-5',
117
265
  // Lets Claude Code's /model picker list our models from /v1/models.
118
266
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
@@ -129,8 +277,57 @@ function configureWindows(apiKey) {
129
277
  return 'Windows user environment';
130
278
  }
131
279
 
132
- function configureUnix(os, apiKey) {
133
- const lines = configLines(apiKey);
280
+ export function mergeEnabledProviders(value) {
281
+ const enabledProviders = Array.isArray(value)
282
+ ? value.filter((entry) => typeof entry === 'string')
283
+ : [];
284
+ return enabledProviders.includes(OPENCODE_PROVIDER_ID)
285
+ ? enabledProviders
286
+ : [...enabledProviders, OPENCODE_PROVIDER_ID];
287
+ }
288
+
289
+ function configureOpenCode(apiKey, model) {
290
+ const directory = join(homedir(), '.config', 'opencode');
291
+ const path = join(directory, 'opencode.json');
292
+ let config = {};
293
+
294
+ if (existsSync(path)) {
295
+ try {
296
+ config = JSON.parse(readFileSync(path, 'utf8'));
297
+ } catch {
298
+ console.log(`Không tự cập nhật OpenCode vì ${path} không phải JSON thuần (có thể là JSONC).`);
299
+ console.log('Thêm enabled_providers: ["virouter"] thủ công để ẩn provider khác.');
300
+ return null;
301
+ }
302
+ }
303
+
304
+ mkdirSync(directory, { recursive: true });
305
+ const provider = config.provider && typeof config.provider === 'object' ? config.provider : {};
306
+ // Keep existing ClaudeKit/ECC/OpenCode providers available. VIROUTER remains the
307
+ // default provider below, but installing it must not erase the user's setup.
308
+ config.enabled_providers = mergeEnabledProviders(config.enabled_providers);
309
+ config.provider = {
310
+ ...provider,
311
+ [OPENCODE_PROVIDER_ID]: {
312
+ ...(provider[OPENCODE_PROVIDER_ID] ?? {}),
313
+ npm: '@ai-sdk/openai-compatible',
314
+ name: 'VIROUTER',
315
+ options: {
316
+ ...(provider[OPENCODE_PROVIDER_ID]?.options ?? {}),
317
+ baseURL: `${API_ORIGIN}/v1`,
318
+ apiKey: '{env:VIROUTER_API_KEY}',
319
+ },
320
+ whitelist: VIROUTER_MODELS,
321
+ },
322
+ };
323
+ config.model = `${OPENCODE_PROVIDER_ID}/${model}`;
324
+ config.small_model = `${OPENCODE_PROVIDER_ID}/claude-haiku-4-5`;
325
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
326
+ return path;
327
+ }
328
+
329
+ function configureUnix(os, apiKey, pinnedModel) {
330
+ const lines = configLines(apiKey, pinnedModel);
134
331
  const profiles = os === '2'
135
332
  ? [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]
136
333
  : [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')];
@@ -139,6 +336,7 @@ function configureUnix(os, apiKey) {
139
336
  return profiles.join(' and ');
140
337
  }
141
338
 
339
+ /** `{ ok: true, me }`, `{ ok: false }` when the gateway rejects the key, or null if unreachable. */
142
340
  async function validateKey(apiKey) {
143
341
  const controller = new AbortController();
144
342
  const timer = setTimeout(() => controller.abort(), 5000);
@@ -147,7 +345,8 @@ async function validateKey(apiKey) {
147
345
  headers: { authorization: `Bearer ${apiKey}` },
148
346
  signal: controller.signal,
149
347
  });
150
- return response.ok;
348
+ if (!response.ok) return { ok: false };
349
+ return { ok: true, me: await response.json().catch(() => null) };
151
350
  } catch {
152
351
  return null;
153
352
  } finally {
@@ -155,6 +354,38 @@ async function validateKey(apiKey) {
155
354
  }
156
355
  }
157
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
+
370
+ function launchClaude(apiKey, pinnedModel) {
371
+ const env = {
372
+ ...process.env,
373
+ ANTHROPIC_BASE_URL: API_ORIGIN,
374
+ ANTHROPIC_AUTH_TOKEN: apiKey,
375
+ ANTHROPIC_SMALL_FAST_MODEL: 'claude-haiku-4-5',
376
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
377
+ };
378
+ delete env.ANTHROPIC_API_KEY;
379
+ // Inherited from an older install's shell, this would override settings.json for this run.
380
+ if (pinnedModel) env.ANTHROPIC_MODEL = pinnedModel;
381
+ else delete env.ANTHROPIC_MODEL;
382
+ execFileSync(process.platform === 'win32' ? 'claude.cmd' : 'claude', [], {
383
+ env,
384
+ stdio: 'inherit',
385
+ shell: process.platform === 'win32',
386
+ });
387
+ }
388
+
158
389
  function escapeRegExp(value) {
159
390
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
160
391
  }
@@ -165,35 +396,92 @@ async function main() {
165
396
  return;
166
397
  }
167
398
 
168
- 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('');
404
+ const agentToolkit = detectAgentToolkit();
405
+ if (agentToolkit) step(`Phát hiện ${agentToolkit}`, '— giữ nguyên skills, rules và hooks hiện có');
169
406
  let input;
170
407
 
171
408
  try {
172
- const apiKey = process.env.VIROUTER_API_KEY || await readSecret('Auth Token / API Key: ');
409
+ const previous = existingConfig();
410
+ const reuseEnvironmentKey = process.argv.includes('--reuse-key') || !process.stdin.isTTY;
411
+ const apiKey = reuseEnvironmentKey && process.env.VIROUTER_API_KEY
412
+ ? process.env.VIROUTER_API_KEY
413
+ : await readVisibleInput(`${style.bold('API key')} ${style.dim('(viro_…, hiển thị khi nhập)')}: `);
173
414
  if (!/^viro_[A-Za-z0-9_-]{20,}$/.test(apiKey)) {
174
415
  throw new Error('API key không hợp lệ. Key phải bắt đầu bằng viro_.');
175
416
  }
176
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
+
177
425
  input = createInterface({ input: process.stdin, output: process.stdout });
178
- const os = await chooseOs(input);
426
+ const { os } = machine;
427
+ const keyChanged = previous.key !== null && previous.key !== apiKey;
428
+ const claudeSettings = readClaudeSettings();
429
+ // Re-running the installer keeps whatever the customer last picked with /model.
430
+ const remembered = resolveStartingModel(claudeSettings.settings.model, previous.model);
431
+ const model = !keyChanged && remembered
432
+ ? remembered
433
+ : await chooseModel(input, remembered ?? DEFAULT_MODEL);
179
434
  input.close();
180
435
  input = undefined;
436
+ console.log('');
181
437
 
182
- const valid = await validateKey(apiKey);
183
- if (valid === false) {
184
- throw new Error(`API key bị từ chối bởi ${API_ORIGIN}. Kiểm tra key hoặc VIROUTER_API_ORIGIN.`);
438
+ // Remember the model in settings.json; pin it in the environment only if that file is not
439
+ // ours to rewrite (unparseable), since a session with no model at all would be worse.
440
+ let pinnedModel = null;
441
+ if (claudeSettings.writable) {
442
+ writeClaudeSettings(claudeSettings.path, withRememberedModel(claudeSettings.settings, model));
443
+ } else {
444
+ pinnedModel = model;
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.'));
185
447
  }
186
- 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.');
187
448
 
188
- const location = os === '1' ? configureWindows(apiKey) : configureUnix(os, apiKey);
189
- console.log(`\nĐã cấu hình Claude Code trong ${location}.`);
190
- console.log('Đóng terminal này, mở terminal mới rồi chạy:');
191
- console.log(' claude\n');
449
+ const location = os === '1'
450
+ ? configureWindows(apiKey, pinnedModel)
451
+ : configureUnix(os, apiKey, pinnedModel);
452
+ const openCodeConfig = configureOpenCode(apiKey, model);
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);
456
+ if (process.argv.includes('--no-launch')) {
457
+ console.log(`\nMở terminal mới rồi chạy ${style.cyan('claude')}.\n`);
458
+ } else {
459
+ console.log(`\n${style.cyan('→')} Khởi chạy Claude Code với VIROUTER…\n`);
460
+ launchClaude(apiKey, pinnedModel);
461
+ }
192
462
  } catch (error) {
193
463
  input?.close();
194
- 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)}`);
195
465
  process.exitCode = 1;
196
466
  }
197
467
  }
198
468
 
199
- await main();
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()) {
486
+ await main();
487
+ }
package/package.json CHANGED
@@ -1,23 +1,14 @@
1
1
  {
2
2
  "name": "virouter",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Configure Claude Code to use the VIROUTER API gateway",
5
- "keywords": [
6
- "claude-code",
7
- "anthropic",
8
- "openai",
9
- "api-gateway",
10
- "cli"
11
- ],
5
+ "keywords": ["claude-code", "anthropic", "openai", "api-gateway", "cli"],
12
6
  "license": "MIT",
13
7
  "type": "module",
14
8
  "bin": {
15
9
  "virouter": "bin/virouter.mjs"
16
10
  },
17
- "files": [
18
- "bin",
19
- "README.md"
20
- ],
11
+ "files": ["bin", "README.md"],
21
12
  "engines": {
22
13
  "node": ">=20"
23
14
  },
@@ -25,6 +16,7 @@
25
16
  "access": "public"
26
17
  },
27
18
  "scripts": {
28
- "typecheck": "node --check bin/virouter.mjs"
19
+ "typecheck": "node --check bin/virouter.mjs",
20
+ "test": "node --test test/*.test.mjs"
29
21
  }
30
- }
22
+ }