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.
- package/README.md +49 -12
- package/bin/virouter.mjs +428 -65
- 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
|
|
21
|
-
|
|
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
|
|
47
|
-
share
|
|
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.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
?
|
|
79
|
-
:
|
|
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
|
-
/**
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
392
|
-
console.log('
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
if (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(
|
|
750
|
+
console.log(`\nMở terminal mới rồi chạy ${style.cyan('claude')}.\n`);
|
|
404
751
|
} else {
|
|
405
|
-
console.log('
|
|
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(`\
|
|
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
|
-
|
|
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
|
}
|