virouter 0.2.1 → 0.2.4
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 +47 -7
- package/bin/virouter.mjs +288 -62
- package/package.json +3 -2
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,48 @@ $env:VIROUTER_API_ORIGIN="http://127.0.0.1:8787"
|
|
|
13
17
|
npx virouter@latest
|
|
14
18
|
```
|
|
15
19
|
|
|
16
|
-
|
|
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
|
|
26
|
+
|
|
27
|
+
- `ANTHROPIC_BASE_URL=https://virouter.click`
|
|
28
|
+
- `ANTHROPIC_AUTH_TOKEN`
|
|
29
|
+
- `model` in `~/.claude/settings.json` — `claude-fable-5-1` on a first install
|
|
30
|
+
- `ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5`
|
|
31
|
+
- `VIROUTER_API_KEY` for OpenCode
|
|
32
|
+
- `~/.config/opencode/opencode.json` with VIROUTER as the default provider
|
|
33
|
+
|
|
34
|
+
Interactive runs always ask for the key again and overwrite local configuration with the key you
|
|
35
|
+
paste. The previous key remains active on the server until you revoke it separately. Running with
|
|
36
|
+
the same key keeps the existing model; a new key asks for the model again.
|
|
37
|
+
|
|
38
|
+
The model is remembered, not pinned. It is written to `model` in `~/.claude/settings.json`, which is
|
|
39
|
+
where Claude Code's `/model` saves a new choice, so every new `claude` session starts on the model
|
|
40
|
+
used last. `ANTHROPIC_MODEL` is deliberately removed (`unset` in the shell profile, deleted from
|
|
41
|
+
the Windows user environment): it applies over the settings file and would send every session
|
|
42
|
+
back to the install-time model. An install that pinned it is migrated — that model moves into
|
|
43
|
+
`settings.json`. Only if `settings.json` is not valid JSON does the installer leave it untouched
|
|
44
|
+
and fall back to pinning `ANTHROPIC_MODEL`.
|
|
45
|
+
|
|
46
|
+
The installer uses visible input and prints the received key for confirmation. Do not record or
|
|
47
|
+
share that terminal output.
|
|
48
|
+
|
|
49
|
+
When an OpenCode configuration already exists, its enabled providers and credentials are
|
|
50
|
+
preserved; VIROUTER is appended and selected as the default provider. A new configuration
|
|
51
|
+
enables only VIROUTER. This allows existing ClaudeKit/ECC/OpenCode workflows to continue
|
|
52
|
+
without losing their provider setup.
|
|
53
|
+
|
|
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.
|
|
17
58
|
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
- `ANTHROPIC_SMALL_FAST_MODEL=viro-speed`
|
|
59
|
+
The customer key is never stored in this package or repository. Obtain it from the one-time
|
|
60
|
+
claim/recovery flow documented in `docs/claude-code-setup.md`; do not use an upstream
|
|
61
|
+
`OPENCODE_GO_API_KEYS` credential here.
|
|
22
62
|
|
|
23
63
|
Then open a new terminal and run:
|
|
24
64
|
|
|
@@ -27,4 +67,4 @@ claude
|
|
|
27
67
|
```
|
|
28
68
|
|
|
29
69
|
It does not install a local server or a model. The gateway remains hosted at
|
|
30
|
-
`https://virouter
|
|
70
|
+
`https://virouter.click`.
|
package/bin/virouter.mjs
CHANGED
|
@@ -1,78 +1,177 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { homedir } 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
9
|
|
|
9
|
-
const API_ORIGIN = (process.env.VIROUTER_API_ORIGIN || 'https://virouter
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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.`);
|
|
29
43
|
}
|
|
30
44
|
|
|
31
|
-
async function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
};
|
|
62
|
-
|
|
63
|
-
process.stdout.write(prompt);
|
|
64
|
-
process.stdin.setRawMode(true);
|
|
65
|
-
process.stdin.resume();
|
|
66
|
-
process.stdin.on('data', onData);
|
|
67
|
-
});
|
|
45
|
+
async function readVisibleInput(prompt) {
|
|
46
|
+
const line = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
47
|
+
const answer = await line.question(prompt);
|
|
48
|
+
line.close();
|
|
49
|
+
const value = answer.trim();
|
|
50
|
+
console.log(`\nĐã nhận customer key: ${value}`);
|
|
51
|
+
return value;
|
|
68
52
|
}
|
|
69
53
|
|
|
70
54
|
async function chooseOs(input) {
|
|
71
|
-
|
|
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();
|
|
72
60
|
if (!['1', '2', '3'].includes(answer)) throw new Error('Chọn 1, 2 hoặc 3.');
|
|
73
61
|
return answer;
|
|
74
62
|
}
|
|
75
63
|
|
|
64
|
+
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}`));
|
|
67
|
+
const defaultIndex = Math.max(0, VIROUTER_MODELS.indexOf(currentModel));
|
|
68
|
+
const answer = (await input.question(`Chọn số [${defaultIndex + 1}]: `)).trim() || String(defaultIndex + 1);
|
|
69
|
+
const selected = VIROUTER_MODELS[Number(answer) - 1];
|
|
70
|
+
if (!selected) throw new Error('Lựa chọn model không hợp lệ.');
|
|
71
|
+
return selected;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function existingConfig() {
|
|
75
|
+
const candidates = process.platform === 'win32'
|
|
76
|
+
? []
|
|
77
|
+
: process.platform === 'darwin'
|
|
78
|
+
? [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]
|
|
79
|
+
: [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')];
|
|
80
|
+
const sources = candidates
|
|
81
|
+
.filter((path) => existsSync(path))
|
|
82
|
+
.map((path) => readFileSync(path, 'utf8'));
|
|
83
|
+
const combined = sources.join('\n');
|
|
84
|
+
const key = combined.match(/export VIROUTER_API_KEY="([^"]+)"/)?.[1]
|
|
85
|
+
|| process.env.VIROUTER_API_KEY
|
|
86
|
+
|| null;
|
|
87
|
+
const model = combined.match(/export ANTHROPIC_MODEL="([^"]+)"/)?.[1]
|
|
88
|
+
|| process.env.ANTHROPIC_MODEL
|
|
89
|
+
|| null;
|
|
90
|
+
return { key, model: VIROUTER_MODELS.includes(model) ? model : null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Claude Code's user settings. `/model` writes the customer's pick to `model` here and every
|
|
95
|
+
* new session starts on it — unless `ANTHROPIC_MODEL` is set, which applies over the `model`
|
|
96
|
+
* key from any file. Earlier installers exported ANTHROPIC_MODEL, so every new `claude` went
|
|
97
|
+
* back to the install-time model whatever the customer had switched to. The model now lives
|
|
98
|
+
* only here, where `/model` can change it.
|
|
99
|
+
*/
|
|
100
|
+
function claudeSettingsPath() {
|
|
101
|
+
return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readClaudeSettings() {
|
|
105
|
+
const path = claudeSettingsPath();
|
|
106
|
+
if (!existsSync(path)) return { path, settings: {}, writable: true };
|
|
107
|
+
try {
|
|
108
|
+
const settings = JSON.parse(readFileSync(path, 'utf8'));
|
|
109
|
+
return settings && typeof settings === 'object' && !Array.isArray(settings)
|
|
110
|
+
? { path, settings, writable: true }
|
|
111
|
+
: { path, settings: {}, writable: false };
|
|
112
|
+
} catch {
|
|
113
|
+
// Never overwrite a file we could not parse; the caller falls back to the env variable.
|
|
114
|
+
return { path, settings: {}, writable: false };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The model a (re)install should keep: the customer's last `/model` choice, else the one an
|
|
120
|
+
* older installer pinned in ANTHROPIC_MODEL, else nothing (first install → DEFAULT_MODEL).
|
|
121
|
+
*/
|
|
122
|
+
export function resolveStartingModel(settingsModel, legacyModel) {
|
|
123
|
+
if (VIROUTER_MODELS.includes(settingsModel)) return settingsModel;
|
|
124
|
+
if (VIROUTER_MODELS.includes(legacyModel)) return legacyModel;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Returns new settings with `model` set and any ANTHROPIC_MODEL pin removed from `env`. */
|
|
129
|
+
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;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function writeClaudeSettings(path, settings) {
|
|
139
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
140
|
+
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function detectAgentToolkit() {
|
|
144
|
+
const candidates = [
|
|
145
|
+
join(process.cwd(), '.claude', '.ck.json'),
|
|
146
|
+
join(process.cwd(), '.claude', 'metadata.json'),
|
|
147
|
+
join(homedir(), '.claude', '.ck.json'),
|
|
148
|
+
join(homedir(), '.claude', 'metadata.json'),
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
for (const path of candidates) {
|
|
152
|
+
if (!existsSync(path)) continue;
|
|
153
|
+
try {
|
|
154
|
+
const config = JSON.parse(readFileSync(path, 'utf8'));
|
|
155
|
+
if (config.kits?.engineer || config.kits?.['ClaudeKit Engineer'] || config.plan) {
|
|
156
|
+
return 'ClaudeKit';
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// A malformed optional toolkit file must not block gateway installation.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const eccManifest = join(process.cwd(), '.claude', 'plugin.json');
|
|
164
|
+
if (existsSync(eccManifest)) {
|
|
165
|
+
try {
|
|
166
|
+
if (JSON.parse(readFileSync(eccManifest, 'utf8')).name === 'ecc') return 'ECC';
|
|
167
|
+
} catch {
|
|
168
|
+
// Keep installation independent from optional local agent configuration.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
76
175
|
/**
|
|
77
176
|
* Claude Code needs ANTHROPIC_AUTH_TOKEN, not ANTHROPIC_API_KEY.
|
|
78
177
|
*
|
|
@@ -85,13 +184,21 @@ async function chooseOs(input) {
|
|
|
85
184
|
* The unset matters: an ANTHROPIC_API_KEY left over from an earlier install keeps
|
|
86
185
|
* shadowing the token, so the fix has to remove it rather than just add the right variable.
|
|
87
186
|
*/
|
|
88
|
-
function configLines(apiKey) {
|
|
187
|
+
function configLines(apiKey, pinnedModel) {
|
|
89
188
|
return [
|
|
189
|
+
'export PATH="$HOME/.local/bin:$PATH"',
|
|
90
190
|
'unset ANTHROPIC_API_KEY',
|
|
91
191
|
`export ANTHROPIC_BASE_URL="${API_ORIGIN}"`,
|
|
92
192
|
`export ANTHROPIC_AUTH_TOKEN="${apiKey}"`,
|
|
93
|
-
|
|
94
|
-
|
|
193
|
+
`export VIROUTER_API_KEY="${apiKey}"`,
|
|
194
|
+
// The model lives in ~/.claude/settings.json so `/model` is remembered. Only when that
|
|
195
|
+
// file could not be written is it pinned here instead.
|
|
196
|
+
pinnedModel ? `export ANTHROPIC_MODEL="${pinnedModel}"` : 'unset ANTHROPIC_MODEL',
|
|
197
|
+
'export ANTHROPIC_SMALL_FAST_MODEL="claude-haiku-4-5"',
|
|
198
|
+
// Lets Claude Code's /model picker list our models from /v1/models. Nonessential
|
|
199
|
+
// traffic must stay enabled or discovery is skipped.
|
|
200
|
+
'export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY="1"',
|
|
201
|
+
'unset CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
|
95
202
|
];
|
|
96
203
|
}
|
|
97
204
|
|
|
@@ -104,15 +211,29 @@ function updateProfile(path, lines) {
|
|
|
104
211
|
return path;
|
|
105
212
|
}
|
|
106
213
|
|
|
107
|
-
function configureWindows(apiKey) {
|
|
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
|
+
}
|
|
223
|
+
}
|
|
108
224
|
const values = {
|
|
109
225
|
ANTHROPIC_BASE_URL: API_ORIGIN,
|
|
110
226
|
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
111
|
-
|
|
112
|
-
|
|
227
|
+
VIROUTER_API_KEY: apiKey,
|
|
228
|
+
...(pinnedModel ? { ANTHROPIC_MODEL: pinnedModel } : {}),
|
|
229
|
+
ANTHROPIC_SMALL_FAST_MODEL: 'claude-haiku-4-5',
|
|
230
|
+
// Lets Claude Code's /model picker list our models from /v1/models.
|
|
231
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
113
232
|
// Blanked rather than left alone — see configLines. A value here shadows the token and
|
|
114
233
|
// sends the user round the /login loop. `setx` has no delete, so empty is the removal.
|
|
115
234
|
ANTHROPIC_API_KEY: '',
|
|
235
|
+
// Discovery is skipped while this is set; clear it.
|
|
236
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '',
|
|
116
237
|
};
|
|
117
238
|
|
|
118
239
|
for (const [name, value] of Object.entries(values)) {
|
|
@@ -121,8 +242,57 @@ function configureWindows(apiKey) {
|
|
|
121
242
|
return 'Windows user environment';
|
|
122
243
|
}
|
|
123
244
|
|
|
124
|
-
function
|
|
125
|
-
const
|
|
245
|
+
export function mergeEnabledProviders(value) {
|
|
246
|
+
const enabledProviders = Array.isArray(value)
|
|
247
|
+
? value.filter((entry) => typeof entry === 'string')
|
|
248
|
+
: [];
|
|
249
|
+
return enabledProviders.includes(OPENCODE_PROVIDER_ID)
|
|
250
|
+
? enabledProviders
|
|
251
|
+
: [...enabledProviders, OPENCODE_PROVIDER_ID];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function configureOpenCode(apiKey, model) {
|
|
255
|
+
const directory = join(homedir(), '.config', 'opencode');
|
|
256
|
+
const path = join(directory, 'opencode.json');
|
|
257
|
+
let config = {};
|
|
258
|
+
|
|
259
|
+
if (existsSync(path)) {
|
|
260
|
+
try {
|
|
261
|
+
config = JSON.parse(readFileSync(path, 'utf8'));
|
|
262
|
+
} catch {
|
|
263
|
+
console.log(`Không tự cập nhật OpenCode vì ${path} không phải JSON thuần (có thể là JSONC).`);
|
|
264
|
+
console.log('Thêm enabled_providers: ["virouter"] thủ công để ẩn provider khác.');
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
mkdirSync(directory, { recursive: true });
|
|
270
|
+
const provider = config.provider && typeof config.provider === 'object' ? config.provider : {};
|
|
271
|
+
// Keep existing ClaudeKit/ECC/OpenCode providers available. VIROUTER remains the
|
|
272
|
+
// default provider below, but installing it must not erase the user's setup.
|
|
273
|
+
config.enabled_providers = mergeEnabledProviders(config.enabled_providers);
|
|
274
|
+
config.provider = {
|
|
275
|
+
...provider,
|
|
276
|
+
[OPENCODE_PROVIDER_ID]: {
|
|
277
|
+
...(provider[OPENCODE_PROVIDER_ID] ?? {}),
|
|
278
|
+
npm: '@ai-sdk/openai-compatible',
|
|
279
|
+
name: 'VIROUTER',
|
|
280
|
+
options: {
|
|
281
|
+
...(provider[OPENCODE_PROVIDER_ID]?.options ?? {}),
|
|
282
|
+
baseURL: `${API_ORIGIN}/v1`,
|
|
283
|
+
apiKey: '{env:VIROUTER_API_KEY}',
|
|
284
|
+
},
|
|
285
|
+
whitelist: VIROUTER_MODELS,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
config.model = `${OPENCODE_PROVIDER_ID}/${model}`;
|
|
289
|
+
config.small_model = `${OPENCODE_PROVIDER_ID}/claude-haiku-4-5`;
|
|
290
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
291
|
+
return path;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function configureUnix(os, apiKey, pinnedModel) {
|
|
295
|
+
const lines = configLines(apiKey, pinnedModel);
|
|
126
296
|
const profiles = os === '2'
|
|
127
297
|
? [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]
|
|
128
298
|
: [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')];
|
|
@@ -147,6 +317,25 @@ async function validateKey(apiKey) {
|
|
|
147
317
|
}
|
|
148
318
|
}
|
|
149
319
|
|
|
320
|
+
function launchClaude(apiKey, pinnedModel) {
|
|
321
|
+
const env = {
|
|
322
|
+
...process.env,
|
|
323
|
+
ANTHROPIC_BASE_URL: API_ORIGIN,
|
|
324
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
325
|
+
ANTHROPIC_SMALL_FAST_MODEL: 'claude-haiku-4-5',
|
|
326
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
327
|
+
};
|
|
328
|
+
delete env.ANTHROPIC_API_KEY;
|
|
329
|
+
// Inherited from an older install's shell, this would override settings.json for this run.
|
|
330
|
+
if (pinnedModel) env.ANTHROPIC_MODEL = pinnedModel;
|
|
331
|
+
else delete env.ANTHROPIC_MODEL;
|
|
332
|
+
execFileSync(process.platform === 'win32' ? 'claude.cmd' : 'claude', [], {
|
|
333
|
+
env,
|
|
334
|
+
stdio: 'inherit',
|
|
335
|
+
shell: process.platform === 'win32',
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
150
339
|
function escapeRegExp(value) {
|
|
151
340
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
152
341
|
}
|
|
@@ -158,16 +347,31 @@ async function main() {
|
|
|
158
347
|
}
|
|
159
348
|
|
|
160
349
|
console.log(`\nVIROUTER Claude Code installer\nTarget: ${API_ORIGIN}\n`);
|
|
350
|
+
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
|
+
}
|
|
161
354
|
let input;
|
|
162
355
|
|
|
163
356
|
try {
|
|
164
|
-
const
|
|
357
|
+
const previous = existingConfig();
|
|
358
|
+
const reuseEnvironmentKey = process.argv.includes('--reuse-key') || !process.stdin.isTTY;
|
|
359
|
+
const apiKey = reuseEnvironmentKey && process.env.VIROUTER_API_KEY
|
|
360
|
+
? process.env.VIROUTER_API_KEY
|
|
361
|
+
: await readVisibleInput('Auth Token / API Key (sẽ hiển thị khi nhập): ');
|
|
165
362
|
if (!/^viro_[A-Za-z0-9_-]{20,}$/.test(apiKey)) {
|
|
166
363
|
throw new Error('API key không hợp lệ. Key phải bắt đầu bằng viro_.');
|
|
167
364
|
}
|
|
168
365
|
|
|
169
366
|
input = createInterface({ input: process.stdin, output: process.stdout });
|
|
170
367
|
const os = await chooseOs(input);
|
|
368
|
+
const keyChanged = previous.key !== null && previous.key !== apiKey;
|
|
369
|
+
const claudeSettings = readClaudeSettings();
|
|
370
|
+
// Re-running the installer keeps whatever the customer last picked with /model.
|
|
371
|
+
const remembered = resolveStartingModel(claudeSettings.settings.model, previous.model);
|
|
372
|
+
const model = !keyChanged && remembered
|
|
373
|
+
? remembered
|
|
374
|
+
: await chooseModel(input, remembered ?? DEFAULT_MODEL);
|
|
171
375
|
input.close();
|
|
172
376
|
input = undefined;
|
|
173
377
|
|
|
@@ -177,10 +381,30 @@ async function main() {
|
|
|
177
381
|
}
|
|
178
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.');
|
|
179
383
|
|
|
180
|
-
|
|
384
|
+
// Remember the model in settings.json; pin it in the environment only if that file is not
|
|
385
|
+
// ours to rewrite (unparseable), since a session with no model at all would be worse.
|
|
386
|
+
let pinnedModel = null;
|
|
387
|
+
if (claudeSettings.writable) {
|
|
388
|
+
writeClaudeSettings(claudeSettings.path, withRememberedModel(claudeSettings.settings, model));
|
|
389
|
+
} else {
|
|
390
|
+
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.');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const location = os === '1'
|
|
396
|
+
? configureWindows(apiKey, pinnedModel)
|
|
397
|
+
: configureUnix(os, apiKey, pinnedModel);
|
|
398
|
+
const openCodeConfig = configureOpenCode(apiKey, model);
|
|
181
399
|
console.log(`\nĐã cấu hình Claude Code trong ${location}.`);
|
|
182
|
-
console.log(
|
|
183
|
-
console.log(
|
|
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}.`);
|
|
402
|
+
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');
|
|
404
|
+
} else {
|
|
405
|
+
console.log('\nKhởi chạy Claude Code với VIROUTER...\n');
|
|
406
|
+
launchClaude(apiKey, pinnedModel);
|
|
407
|
+
}
|
|
184
408
|
} catch (error) {
|
|
185
409
|
input?.close();
|
|
186
410
|
console.error(`\nLỗi: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -188,4 +412,6 @@ async function main() {
|
|
|
188
412
|
}
|
|
189
413
|
}
|
|
190
414
|
|
|
191
|
-
|
|
415
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
416
|
+
await main();
|
|
417
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "virouter",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"access": "public"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"typecheck": "node --check bin/virouter.mjs"
|
|
19
|
+
"typecheck": "node --check bin/virouter.mjs",
|
|
20
|
+
"test": "node --test test/*.test.mjs"
|
|
20
21
|
}
|
|
21
22
|
}
|