zyrouter 0.1.1 → 0.1.3
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 +1 -1
- package/lib/configs.mjs +162 -147
- package/lib/main.mjs +425 -414
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ to `~/.zyrouter/backups/`, writes the ZyRouter settings, and sends one small tes
|
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| Codex (CLI & VS Code) | `~/.codex/config.toml` and the `ZYROUTER_API_KEY` user environment variable |
|
|
15
15
|
| Claude Code | `~/.claude/settings.json` |
|
|
16
|
-
| OpenCode | `~/.config/opencode/opencode.json`
|
|
16
|
+
| OpenCode | `~/.config/opencode/opencode.json` (key written in the file); an existing ZyRouter entry in `~/.local/share/opencode/auth.json` is updated too |
|
|
17
17
|
| OpenClaw | `~/.openclaw/openclaw.json` |
|
|
18
18
|
| Hermes Agent | `~/.hermes/config.yaml` (the `model:` block) and `OPENAI_API_KEY` in `~/.hermes/.env` |
|
|
19
19
|
|
package/lib/configs.mjs
CHANGED
|
@@ -1,147 +1,162 @@
|
|
|
1
|
-
// Pure config mergers: each takes the current file content (or parsed JSON) and returns the new one.
|
|
2
|
-
// They only touch ZyRouter's own entries, so the customer's other providers and settings survive.
|
|
3
|
-
|
|
4
|
-
export const PROVIDER_ID = "zyrouter";
|
|
5
|
-
export const ENV_KEY = "ZYROUTER_API_KEY";
|
|
6
|
-
|
|
7
|
-
const SAFE_VALUE = /^[A-Za-z0-9._:/\-[\]]+$/;
|
|
8
|
-
|
|
9
|
-
export function assertSafeValue(label, value) {
|
|
10
|
-
if (typeof value !== "string" || !SAFE_VALUE.test(value)) throw new Error(`Invalid ${label}: ${value}`);
|
|
11
|
-
return value;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Codex ~/.codex/config.toml: sets the top-level model/model_provider, keeps an existing
|
|
16
|
-
* model_reasoning_effort, and replaces any previous [model_providers.zyrouter] table.
|
|
17
|
-
*/
|
|
18
|
-
export function mergeCodexToml(existing, { baseUrl, model }) {
|
|
19
|
-
assertSafeValue("base URL", baseUrl);
|
|
20
|
-
assertSafeValue("model", model);
|
|
21
|
-
const lines = String(existing || "").replace(/\r\n/g, "\n").split("\n");
|
|
22
|
-
const kept = [];
|
|
23
|
-
let section = null;
|
|
24
|
-
let skipping = false;
|
|
25
|
-
let hasEffort = false;
|
|
26
|
-
for (const line of lines) {
|
|
27
|
-
const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/.exec(line);
|
|
28
|
-
if (header) {
|
|
29
|
-
section = header[1].replace(/"/g, "").trim();
|
|
30
|
-
skipping = section === `model_providers.${PROVIDER_ID}`;
|
|
31
|
-
if (!skipping) kept.push(line);
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
if (skipping) continue;
|
|
35
|
-
if (section === null) {
|
|
36
|
-
if (/^\s*(model|model_provider)\s*=/.test(line)) continue;
|
|
37
|
-
if (/^\s*model_reasoning_effort\s*=/.test(line)) hasEffort = true;
|
|
38
|
-
}
|
|
39
|
-
kept.push(line);
|
|
40
|
-
}
|
|
41
|
-
while (kept.length && kept[0].trim() === "") kept.shift();
|
|
42
|
-
while (kept.length && kept[kept.length - 1].trim() === "") kept.pop();
|
|
43
|
-
const head = [`model = "${model}"`, `model_provider = "${PROVIDER_ID}"`];
|
|
44
|
-
if (!hasEffort) head.push('model_reasoning_effort = "high"');
|
|
45
|
-
const provider = [
|
|
46
|
-
`[model_providers.${PROVIDER_ID}]`,
|
|
47
|
-
'name = "ZyRouter"',
|
|
48
|
-
`base_url = "${baseUrl}"`,
|
|
49
|
-
`env_key = "${ENV_KEY}"`,
|
|
50
|
-
'wire_api = "responses"',
|
|
51
|
-
];
|
|
52
|
-
// Top-level keys must come before the first table, so ours go first and the provider table last.
|
|
53
|
-
return [...head, ...(kept.length ? ["", ...kept] : []), "", ...provider, ""].join("\n");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Claude Code ~/.claude/settings.json: points the Anthropic client at ZyRouter. */
|
|
57
|
-
export function mergeClaudeSettings(data, { anthropicBaseUrl, key, model }) {
|
|
58
|
-
const next = { ...(data || {}) };
|
|
59
|
-
next.env = { ...(next.env || {}), ANTHROPIC_BASE_URL: anthropicBaseUrl, ANTHROPIC_AUTH_TOKEN: key, ANTHROPIC_MODEL: model };
|
|
60
|
-
return next;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** OpenCode ~/.config/opencode/opencode.json: an OpenAI-compatible provider reading the key from the environment. */
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
};
|
|
88
|
-
next.
|
|
89
|
-
next.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
1
|
+
// Pure config mergers: each takes the current file content (or parsed JSON) and returns the new one.
|
|
2
|
+
// They only touch ZyRouter's own entries, so the customer's other providers and settings survive.
|
|
3
|
+
|
|
4
|
+
export const PROVIDER_ID = "zyrouter";
|
|
5
|
+
export const ENV_KEY = "ZYROUTER_API_KEY";
|
|
6
|
+
|
|
7
|
+
const SAFE_VALUE = /^[A-Za-z0-9._:/\-[\]]+$/;
|
|
8
|
+
|
|
9
|
+
export function assertSafeValue(label, value) {
|
|
10
|
+
if (typeof value !== "string" || !SAFE_VALUE.test(value)) throw new Error(`Invalid ${label}: ${value}`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Codex ~/.codex/config.toml: sets the top-level model/model_provider, keeps an existing
|
|
16
|
+
* model_reasoning_effort, and replaces any previous [model_providers.zyrouter] table.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeCodexToml(existing, { baseUrl, model }) {
|
|
19
|
+
assertSafeValue("base URL", baseUrl);
|
|
20
|
+
assertSafeValue("model", model);
|
|
21
|
+
const lines = String(existing || "").replace(/\r\n/g, "\n").split("\n");
|
|
22
|
+
const kept = [];
|
|
23
|
+
let section = null;
|
|
24
|
+
let skipping = false;
|
|
25
|
+
let hasEffort = false;
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/.exec(line);
|
|
28
|
+
if (header) {
|
|
29
|
+
section = header[1].replace(/"/g, "").trim();
|
|
30
|
+
skipping = section === `model_providers.${PROVIDER_ID}`;
|
|
31
|
+
if (!skipping) kept.push(line);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (skipping) continue;
|
|
35
|
+
if (section === null) {
|
|
36
|
+
if (/^\s*(model|model_provider)\s*=/.test(line)) continue;
|
|
37
|
+
if (/^\s*model_reasoning_effort\s*=/.test(line)) hasEffort = true;
|
|
38
|
+
}
|
|
39
|
+
kept.push(line);
|
|
40
|
+
}
|
|
41
|
+
while (kept.length && kept[0].trim() === "") kept.shift();
|
|
42
|
+
while (kept.length && kept[kept.length - 1].trim() === "") kept.pop();
|
|
43
|
+
const head = [`model = "${model}"`, `model_provider = "${PROVIDER_ID}"`];
|
|
44
|
+
if (!hasEffort) head.push('model_reasoning_effort = "high"');
|
|
45
|
+
const provider = [
|
|
46
|
+
`[model_providers.${PROVIDER_ID}]`,
|
|
47
|
+
'name = "ZyRouter"',
|
|
48
|
+
`base_url = "${baseUrl}"`,
|
|
49
|
+
`env_key = "${ENV_KEY}"`,
|
|
50
|
+
'wire_api = "responses"',
|
|
51
|
+
];
|
|
52
|
+
// Top-level keys must come before the first table, so ours go first and the provider table last.
|
|
53
|
+
return [...head, ...(kept.length ? ["", ...kept] : []), "", ...provider, ""].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Claude Code ~/.claude/settings.json: points the Anthropic client at ZyRouter. */
|
|
57
|
+
export function mergeClaudeSettings(data, { anthropicBaseUrl, key, model }) {
|
|
58
|
+
const next = { ...(data || {}) };
|
|
59
|
+
next.env = { ...(next.env || {}), ANTHROPIC_BASE_URL: anthropicBaseUrl, ANTHROPIC_AUTH_TOKEN: key, ANTHROPIC_MODEL: model };
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** OpenCode ~/.config/opencode/opencode.json: an OpenAI-compatible provider reading the key from the environment. */
|
|
64
|
+
/**
|
|
65
|
+
* OpenCode ~/.config/opencode/opencode.json. The key is written as a literal, not `{env:ZYROUTER_API_KEY}`:
|
|
66
|
+
* an OpenCode desktop app that was already running keeps the old environment, so a reinstall with a new
|
|
67
|
+
* key would silently keep sending the old one.
|
|
68
|
+
*/
|
|
69
|
+
export function mergeOpenCode(data, { baseUrl, key, models, model }) {
|
|
70
|
+
const next = { ...(data || {}) };
|
|
71
|
+
next.$schema ||= "https://opencode.ai/config.json";
|
|
72
|
+
next.provider = {
|
|
73
|
+
...(next.provider || {}),
|
|
74
|
+
[PROVIDER_ID]: {
|
|
75
|
+
npm: "@ai-sdk/openai-compatible",
|
|
76
|
+
name: "ZyRouter",
|
|
77
|
+
options: { baseURL: baseUrl, apiKey: key },
|
|
78
|
+
models: Object.fromEntries(models.map((id) => [id, { name: id }])),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
next.model = `${PROVIDER_ID}/${model}`;
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** OpenClaw ~/.openclaw/openclaw.json: same shape as the dashboard guide. */
|
|
86
|
+
export function mergeOpenClaw(data, { baseUrl, key, models, model }) {
|
|
87
|
+
const next = { ...(data || {}) };
|
|
88
|
+
next.models = { ...(next.models || {}) };
|
|
89
|
+
next.models.providers = {
|
|
90
|
+
...(next.models.providers || {}),
|
|
91
|
+
[PROVIDER_ID]: { baseUrl, apiKey: key, api: "openai-completions", models: models.map((id) => ({ id, name: id })) },
|
|
92
|
+
};
|
|
93
|
+
next.agents = { ...(next.agents || {}) };
|
|
94
|
+
next.agents.defaults = { ...(next.agents.defaults || {}) };
|
|
95
|
+
next.agents.defaults.model = { ...(next.agents.defaults.model || {}), primary: `${PROVIDER_ID}/${model}` };
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const RC_START = "# >>> zyrouter >>>";
|
|
100
|
+
const RC_END = "# <<< zyrouter <<<";
|
|
101
|
+
|
|
102
|
+
/** Shell rc file: one marked block exporting the key, replaced on every run. */
|
|
103
|
+
export function setRcBlock(existing, key) {
|
|
104
|
+
assertSafeValue("API key", key);
|
|
105
|
+
const without = removeRcBlock(existing);
|
|
106
|
+
const block = `${RC_START}\nexport ${ENV_KEY}="${key}"\n${RC_END}\n`;
|
|
107
|
+
return `${without ? `${without.replace(/\n*$/, "\n")}\n` : ""}${block}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function removeRcBlock(existing) {
|
|
111
|
+
const text = String(existing || "");
|
|
112
|
+
const pattern = new RegExp(`\\n?${RC_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${RC_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, "g");
|
|
113
|
+
return text.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function maskKey(key) {
|
|
117
|
+
const value = String(key || "");
|
|
118
|
+
return value.length > 12 ? `${value.slice(0, 10)}…${value.slice(-4)}` : "sk-zyr-…";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Hermes Agent ~/.hermes/config.yaml: replaces only the top-level `model:` block (the shape the dashboard
|
|
123
|
+
* guide uses) and leaves every other key untouched.
|
|
124
|
+
*/
|
|
125
|
+
export function mergeHermesConfig(existing, { baseUrl, model }) {
|
|
126
|
+
assertSafeValue("base URL", baseUrl);
|
|
127
|
+
assertSafeValue("model", model);
|
|
128
|
+
const block = ["model:", ` default: "${model}"`, ' provider: "custom"', ` base_url: "${baseUrl}"`];
|
|
129
|
+
const lines = String(existing || "").replace(/\r\n/g, "\n").split("\n");
|
|
130
|
+
const start = lines.findIndex((line) => /^model\s*:/.test(line));
|
|
131
|
+
if (start === -1) {
|
|
132
|
+
const rest = lines.join("\n").replace(/^\n+/, "").replace(/\n*$/, "");
|
|
133
|
+
return `${block.join("\n")}\n${rest ? `\n${rest}\n` : ""}`;
|
|
134
|
+
}
|
|
135
|
+
let end = start + 1;
|
|
136
|
+
// The block runs while lines are indented, blank, or comments that belong to it.
|
|
137
|
+
while (end < lines.length && (lines[end].trim() === "" || /^[ \t]/.test(lines[end]))) end += 1;
|
|
138
|
+
while (end > start + 1 && lines[end - 1].trim() === "") end -= 1;
|
|
139
|
+
const merged = [...lines.slice(0, start), ...block, ...lines.slice(end)].join("\n").replace(/\n*$/, "\n");
|
|
140
|
+
return merged;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Hermes Agent ~/.hermes/.env: sets OPENAI_API_KEY, keeping the other variables. */
|
|
144
|
+
export function setDotenvValue(existing, name, value) {
|
|
145
|
+
assertSafeValue(name, value);
|
|
146
|
+
const lines = String(existing || "").replace(/\r\n/g, "\n").split("\n").filter((line, index, all) => !(index === all.length - 1 && line === ""));
|
|
147
|
+
const pattern = new RegExp(`^\s*(export\s+)?${name}\s*=`);
|
|
148
|
+
const index = lines.findIndex((line) => pattern.test(line));
|
|
149
|
+
if (index === -1) lines.push(`${name}=${value}`);
|
|
150
|
+
else lines[index] = `${name}=${value}`;
|
|
151
|
+
return `${lines.join("\n")}\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* OpenCode's own credential store (~/.local/share/opencode/auth.json) wins over the config file. Only an
|
|
156
|
+
* existing "zyrouter" entry is replaced, so a key saved earlier through OpenCode's login cannot shadow the
|
|
157
|
+
* one the installer just wrote. Returns null when there is nothing to change.
|
|
158
|
+
*/
|
|
159
|
+
export function updateOpenCodeAuth(data, { key }) {
|
|
160
|
+
if (!data || typeof data !== "object" || Array.isArray(data) || !data[PROVIDER_ID]) return null;
|
|
161
|
+
return { ...data, [PROVIDER_ID]: { type: "api", key } };
|
|
162
|
+
}
|
package/lib/main.mjs
CHANGED
|
@@ -1,414 +1,425 @@
|
|
|
1
|
-
import os from "node:os";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import { createInterface } from "node:readline/promises";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { parseArgs } from "node:util";
|
|
7
|
-
import { ENV_KEY, maskKey, mergeClaudeSettings, mergeCodexToml, mergeHermesConfig, mergeOpenClaw, mergeOpenCode, setDotenvValue } from "./configs.mjs";
|
|
8
|
-
import { box, createStyle, groupModels, pickFromMenu, readSecret, withSpinner } from "./ui.mjs";
|
|
9
|
-
import { createBackup, defaultRun, exists, hasEditorExtension, onPath, persistApiKey, readJson, readText, removePersistedApiKey, restoreBackups, restoreWindowsUserEnv, writeText } from "./system.mjs";
|
|
10
|
-
|
|
11
|
-
export const DEFAULT_BASE_URL = "https://zyrouter.com/v1";
|
|
12
|
-
const FALLBACK_MODELS = ["gpt-6-astra", "gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-sol", "gpt-6-luna"];
|
|
13
|
-
const KEY_PATTERN = /^sk-zyr-[A-Za-z0-9_-]{20,}$/;
|
|
14
|
-
const VERSION = (() => {
|
|
15
|
-
try {
|
|
16
|
-
return JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
|
|
17
|
-
} catch {
|
|
18
|
-
return "";
|
|
19
|
-
}
|
|
20
|
-
})();
|
|
21
|
-
|
|
22
|
-
const HELP = `ZyRouter installer — hubungkan Codex, Claude Code, OpenCode, OpenClaw, dan Hermes Agent ke ZyRouter.
|
|
23
|
-
|
|
24
|
-
Pakai:
|
|
25
|
-
npx -y zyrouter@latest --key sk-zyr-... pasang (deteksi aplikasi otomatis)
|
|
26
|
-
npx -y zyrouter@latest uninstall kembalikan semua konfigurasi seperti semula
|
|
27
|
-
|
|
28
|
-
Opsi:
|
|
29
|
-
--key <key> API key ZyRouter (atau env ZYROUTER_API_KEY, atau ditanya)
|
|
30
|
-
--model <id> model default, mis. gpt-6.1-terra
|
|
31
|
-
--tools <list> codex,claude-code,opencode,openclaw,hermes (default: yang terdeteksi)
|
|
32
|
-
--yes tanpa pertanyaan: pakai aplikasi yang terdeteksi dan model pertama
|
|
33
|
-
--no-test lewati request tes di akhir
|
|
34
|
-
--no-env jangan simpan ZYROUTER_API_KEY ke environment (untuk tes)
|
|
35
|
-
--dry-run tampilkan rencana tanpa mengubah file
|
|
36
|
-
--base-url <url> endpoint lain (default ${DEFAULT_BASE_URL})
|
|
37
|
-
`;
|
|
38
|
-
|
|
39
|
-
/** Tools whose config files the installer writes; manual ones only get printed instructions. */
|
|
40
|
-
function toolCatalog(ctx) {
|
|
41
|
-
const home = ctx.home;
|
|
42
|
-
return [
|
|
43
|
-
{
|
|
44
|
-
id: "codex",
|
|
45
|
-
name: "Codex (CLI & VS Code)",
|
|
46
|
-
protocol: "responses",
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
detected: () => onPath("
|
|
97
|
-
apply(opts, backup) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (ctx.tty)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (
|
|
305
|
-
out("
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (!
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
failures += 1;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (selected.some((tool) => tool.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { ENV_KEY, maskKey, mergeClaudeSettings, mergeCodexToml, mergeHermesConfig, mergeOpenClaw, mergeOpenCode, setDotenvValue, updateOpenCodeAuth } from "./configs.mjs";
|
|
8
|
+
import { box, createStyle, groupModels, pickFromMenu, readSecret, withSpinner } from "./ui.mjs";
|
|
9
|
+
import { createBackup, defaultRun, exists, hasEditorExtension, onPath, persistApiKey, readJson, readText, removePersistedApiKey, restoreBackups, restoreWindowsUserEnv, writeText } from "./system.mjs";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_BASE_URL = "https://zyrouter.com/v1";
|
|
12
|
+
const FALLBACK_MODELS = ["gpt-6-astra", "gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-sol", "gpt-6-luna"];
|
|
13
|
+
const KEY_PATTERN = /^sk-zyr-[A-Za-z0-9_-]{20,}$/;
|
|
14
|
+
const VERSION = (() => {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
|
|
17
|
+
} catch {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
const HELP = `ZyRouter installer — hubungkan Codex, Claude Code, OpenCode, OpenClaw, dan Hermes Agent ke ZyRouter.
|
|
23
|
+
|
|
24
|
+
Pakai:
|
|
25
|
+
npx -y zyrouter@latest --key sk-zyr-... pasang (deteksi aplikasi otomatis)
|
|
26
|
+
npx -y zyrouter@latest uninstall kembalikan semua konfigurasi seperti semula
|
|
27
|
+
|
|
28
|
+
Opsi:
|
|
29
|
+
--key <key> API key ZyRouter (atau env ZYROUTER_API_KEY, atau ditanya)
|
|
30
|
+
--model <id> model default, mis. gpt-6.1-terra
|
|
31
|
+
--tools <list> codex,claude-code,opencode,openclaw,hermes (default: yang terdeteksi)
|
|
32
|
+
--yes tanpa pertanyaan: pakai aplikasi yang terdeteksi dan model pertama
|
|
33
|
+
--no-test lewati request tes di akhir
|
|
34
|
+
--no-env jangan simpan ZYROUTER_API_KEY ke environment (untuk tes)
|
|
35
|
+
--dry-run tampilkan rencana tanpa mengubah file
|
|
36
|
+
--base-url <url> endpoint lain (default ${DEFAULT_BASE_URL})
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
/** Tools whose config files the installer writes; manual ones only get printed instructions. */
|
|
40
|
+
function toolCatalog(ctx) {
|
|
41
|
+
const home = ctx.home;
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
id: "codex",
|
|
45
|
+
name: "Codex (CLI & VS Code)",
|
|
46
|
+
protocol: "responses",
|
|
47
|
+
// Many Codex users sign in with ChatGPT; switching it to ZyRouter must be picked, not pressed through with Enter.
|
|
48
|
+
optIn: true,
|
|
49
|
+
file: path.join(home, ".codex", "config.toml"),
|
|
50
|
+
detected: () => onPath("codex", ctx) || exists(path.join(home, ".codex")) || hasEditorExtension("openai.chatgpt", ctx),
|
|
51
|
+
apply(opts, backup) {
|
|
52
|
+
backup.record(this.file);
|
|
53
|
+
writeText(this.file, mergeCodexToml(readText(this.file), opts));
|
|
54
|
+
return { ok: true };
|
|
55
|
+
},
|
|
56
|
+
needsEnv: true,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "claude-code",
|
|
60
|
+
name: "Claude Code",
|
|
61
|
+
protocol: "messages",
|
|
62
|
+
// ~/.claude exists for every Claude Code user; redirecting it must be a deliberate choice, never a default.
|
|
63
|
+
optIn: true,
|
|
64
|
+
file: path.join(home, ".claude", "settings.json"),
|
|
65
|
+
detected: () => onPath("claude", ctx) || exists(path.join(home, ".claude")),
|
|
66
|
+
apply(opts, backup) {
|
|
67
|
+
return writeJsonConfig(this.file, (data) => mergeClaudeSettings(data, opts), backup);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "opencode",
|
|
72
|
+
name: "OpenCode",
|
|
73
|
+
protocol: "chat",
|
|
74
|
+
file: path.join(home, ".config", "opencode", "opencode.json"),
|
|
75
|
+
detected: () => onPath("opencode", ctx) || exists(path.join(home, ".config", "opencode")),
|
|
76
|
+
apply(opts, backup) {
|
|
77
|
+
if (exists(path.join(path.dirname(this.file), "opencode.jsonc"))) return { ok: false, reason: "opencode.jsonc dipakai; tambahkan provider secara manual" };
|
|
78
|
+
const written = writeJsonConfig(this.file, (data) => mergeOpenCode(data, opts), backup);
|
|
79
|
+
if (!written.ok) return written;
|
|
80
|
+
const auth = readJson(this.authFile);
|
|
81
|
+
const updatedAuth = auth.ok ? updateOpenCodeAuth(auth.data, opts) : null;
|
|
82
|
+
if (updatedAuth) {
|
|
83
|
+
backup.record(this.authFile);
|
|
84
|
+
writeText(this.authFile, `${JSON.stringify(updatedAuth, null, 2)}
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
return written;
|
|
88
|
+
},
|
|
89
|
+
authFile: path.join(home, ".local", "share", "opencode", "auth.json"),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "openclaw",
|
|
93
|
+
name: "OpenClaw",
|
|
94
|
+
protocol: "chat",
|
|
95
|
+
file: path.join(home, ".openclaw", "openclaw.json"),
|
|
96
|
+
detected: () => onPath("openclaw", ctx) || exists(path.join(home, ".openclaw")),
|
|
97
|
+
apply(opts, backup) {
|
|
98
|
+
return writeJsonConfig(this.file, (data) => mergeOpenClaw(data, opts), backup);
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "hermes",
|
|
103
|
+
name: "Hermes Agent",
|
|
104
|
+
protocol: "chat",
|
|
105
|
+
file: path.join(home, ".hermes", "config.yaml"),
|
|
106
|
+
envFile: path.join(home, ".hermes", ".env"),
|
|
107
|
+
detected: () => onPath("hermes", ctx) || exists(path.join(home, ".hermes")),
|
|
108
|
+
apply(opts, backup) {
|
|
109
|
+
backup.record(this.file);
|
|
110
|
+
backup.record(this.envFile);
|
|
111
|
+
writeText(this.file, mergeHermesConfig(readText(this.file), opts));
|
|
112
|
+
writeText(this.envFile, setDotenvValue(readText(this.envFile), "OPENAI_API_KEY", opts.key));
|
|
113
|
+
return { ok: true };
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const MANUAL_TOOLS = [
|
|
120
|
+
{ id: "cline", name: "Cline", detected: (ctx) => hasEditorExtension("saoudrizwan.claude-dev", ctx), hint: "Settings Cline > API Provider: OpenAI Compatible" },
|
|
121
|
+
{ id: "continue", name: "Continue", detected: (ctx) => hasEditorExtension("continue.continue", ctx) || exists(path.join(ctx.home, ".continue")), hint: "~/.continue/config.yaml, provider openai" },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function writeJsonConfig(file, merge, backup) {
|
|
125
|
+
const current = readJson(file);
|
|
126
|
+
if (!current.ok) return { ok: false, reason: `${path.basename(file)} tidak bisa dibaca sebagai JSON (mungkin ada komentar); ubah manual` };
|
|
127
|
+
backup.record(file);
|
|
128
|
+
writeText(file, `${JSON.stringify(merge(current.data), null, 2)}\n`);
|
|
129
|
+
return { ok: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function fetchModels(ctx, baseUrl, key) {
|
|
133
|
+
try {
|
|
134
|
+
const response = await ctx.fetch(`${baseUrl}/models`, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000) });
|
|
135
|
+
if (response.status === 401 || response.status === 403) return { ok: false, invalidKey: true };
|
|
136
|
+
if (!response.ok) return { ok: false, status: response.status };
|
|
137
|
+
const body = await response.json();
|
|
138
|
+
const models = (Array.isArray(body?.data) ? body.data : []).map((item) => item?.id).filter((id) => typeof id === "string" && id);
|
|
139
|
+
return models.length ? { ok: true, models } : { ok: false, status: response.status };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return { ok: false, error: error?.name || "Error" };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const PROTOCOL_TESTS = {
|
|
146
|
+
chat: {
|
|
147
|
+
label: "OpenAI Chat Completions",
|
|
148
|
+
url: (o) => `${o.baseUrl}/chat/completions`,
|
|
149
|
+
body: (model) => ({ model, messages: [{ role: "user", content: "Reply with exactly: ZyRouter OK" }], max_tokens: 16, stream: false }),
|
|
150
|
+
text: (b) => b?.choices?.[0]?.message?.content,
|
|
151
|
+
},
|
|
152
|
+
responses: {
|
|
153
|
+
label: "OpenAI Responses (Codex)",
|
|
154
|
+
url: (o) => `${o.baseUrl}/responses`,
|
|
155
|
+
body: (model) => ({ model, input: "Reply with exactly: ZyRouter OK", max_output_tokens: 16, stream: false }),
|
|
156
|
+
text: (b) => b?.output_text || (b?.output || []).flatMap((item) => item?.content || []).map((part) => part?.text).filter(Boolean).join(""),
|
|
157
|
+
},
|
|
158
|
+
messages: {
|
|
159
|
+
label: "Anthropic Messages (Claude Code)",
|
|
160
|
+
url: (o) => `${o.anthropicBaseUrl}/v1/messages`,
|
|
161
|
+
headers: { "anthropic-version": "2023-06-01" },
|
|
162
|
+
body: (model) => ({ model, max_tokens: 16, messages: [{ role: "user", content: "Reply with exactly: ZyRouter OK" }] }),
|
|
163
|
+
text: (b) => (b?.content || []).map((part) => part?.text).filter(Boolean).join(""),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
async function testProtocol(ctx, protocol, opts) {
|
|
168
|
+
const spec = PROTOCOL_TESTS[protocol];
|
|
169
|
+
try {
|
|
170
|
+
const response = await ctx.fetch(spec.url(opts), {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { Authorization: `Bearer ${opts.key}`, "Content-Type": "application/json", ...(spec.headers || {}) },
|
|
173
|
+
body: JSON.stringify(spec.body(opts.model)),
|
|
174
|
+
signal: AbortSignal.timeout(90_000),
|
|
175
|
+
});
|
|
176
|
+
const body = await response.json().catch(() => ({}));
|
|
177
|
+
if (!response.ok) return { ok: false, label: spec.label, status: response.status, message: body?.error?.message || body?.message };
|
|
178
|
+
return { ok: true, label: spec.label, text: String(spec.text(body) || "").trim() };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { ok: false, label: spec.label, error: error?.name || "Error" };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseList(value) {
|
|
185
|
+
return String(value || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function run(argv, overrides = {}) {
|
|
189
|
+
const stdinTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
190
|
+
const ctx = {
|
|
191
|
+
env: process.env,
|
|
192
|
+
platform: process.platform,
|
|
193
|
+
home: process.env.ZYROUTER_INSTALLER_HOME || os.homedir(),
|
|
194
|
+
fetch: globalThis.fetch,
|
|
195
|
+
run: defaultRun,
|
|
196
|
+
out: (line = "") => process.stdout.write(`${line}\n`),
|
|
197
|
+
ask: null,
|
|
198
|
+
interactive: stdinTty,
|
|
199
|
+
...overrides,
|
|
200
|
+
};
|
|
201
|
+
// Arrow-key menus, spinner and colors only on a real terminal; tests and pipes get plain text.
|
|
202
|
+
ctx.tty = overrides.tty ?? (!overrides.out && !ctx.ask && ctx.interactive && stdinTty);
|
|
203
|
+
ctx.style = createStyle(overrides.color ?? (ctx.tty && !ctx.env.NO_COLOR));
|
|
204
|
+
const { values, positionals } = parseArgs({
|
|
205
|
+
args: argv,
|
|
206
|
+
allowPositionals: true,
|
|
207
|
+
options: {
|
|
208
|
+
key: { type: "string" },
|
|
209
|
+
model: { type: "string" },
|
|
210
|
+
tools: { type: "string" },
|
|
211
|
+
"base-url": { type: "string" },
|
|
212
|
+
yes: { type: "boolean", short: "y" },
|
|
213
|
+
"no-test": { type: "boolean" },
|
|
214
|
+
"no-env": { type: "boolean" },
|
|
215
|
+
"dry-run": { type: "boolean" },
|
|
216
|
+
help: { type: "boolean", short: "h" },
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
const out = ctx.out;
|
|
220
|
+
const s = ctx.style;
|
|
221
|
+
const ok = (text) => out(`${s.green("✔")} ${text}`);
|
|
222
|
+
const warn = (text) => out(`${s.yellow("!")} ${text}`);
|
|
223
|
+
const fail = (text) => out(`${s.red("✖")} ${text}`);
|
|
224
|
+
const step = (index, total, text) => out(`\n${s.orange(s.bold(`${index}/${total}`))} ${s.bold(text)}`);
|
|
225
|
+
if (values.help) {
|
|
226
|
+
out(HELP);
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
ctx.dryRun = values["dry-run"] === true;
|
|
230
|
+
|
|
231
|
+
// One readline interface per question, closed right away, so raw-mode menus own stdin afterwards.
|
|
232
|
+
const ask = ctx.ask || (async (question) => {
|
|
233
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
234
|
+
try {
|
|
235
|
+
return await rl.question(question);
|
|
236
|
+
} finally {
|
|
237
|
+
rl.close();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
const canAsk = !values.yes && Boolean(ctx.ask || ctx.interactive);
|
|
241
|
+
|
|
242
|
+
if (positionals[0] === "uninstall") {
|
|
243
|
+
const result = restoreBackups(ctx);
|
|
244
|
+
// Windows: put back the value from before the first install. Elsewhere the rc files were restored already.
|
|
245
|
+
if (ctx.platform === "win32") {
|
|
246
|
+
if (result.windowsEnv) restoreWindowsUserEnv(ctx, result.windowsEnvPrevious);
|
|
247
|
+
} else {
|
|
248
|
+
removePersistedApiKey(ctx);
|
|
249
|
+
}
|
|
250
|
+
if (result.sessions) ok(`${result.files} file dikembalikan dari ${result.sessions} backup.`);
|
|
251
|
+
else out("Tidak ada backup ZyRouter untuk dikembalikan.");
|
|
252
|
+
out("Tutup lalu buka lagi terminal dan editor supaya perubahan terbaca.");
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (ctx.tty) {
|
|
257
|
+
out("");
|
|
258
|
+
box(s, [`${s.orange(s.bold("ZyRouter"))} ${s.bold("installer")} ${s.gray(VERSION ? `v${VERSION}` : "")}`, s.gray("Hubungkan coding agent kamu ke ZyRouter")]).forEach((line) => out(line));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const baseUrl = String(values["base-url"] || ctx.env.ZYROUTER_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
262
|
+
if (!/^https:\/\//.test(baseUrl) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(`${baseUrl}/`)) throw new Error("Base URL harus https:// (atau http://localhost untuk tes).");
|
|
263
|
+
const anthropicBaseUrl = baseUrl.replace(/\/v1$/i, "");
|
|
264
|
+
const totalSteps = values["no-test"] ? 4 : 5;
|
|
265
|
+
|
|
266
|
+
// 1. API key. An existing ZYROUTER_API_KEY is only reused when it is a ZyRouter key; other tools use the name too.
|
|
267
|
+
if (ctx.tty) step(1, totalSteps, "API key");
|
|
268
|
+
const envKey = ctx.env[ENV_KEY] || "";
|
|
269
|
+
let key = values.key || (KEY_PATTERN.test(envKey) ? envKey : "");
|
|
270
|
+
if (!values.key && envKey && !key) warn(`${ENV_KEY} di environment bukan key ZyRouter; diabaikan.`);
|
|
271
|
+
// Ask for a missing key even when stdin is not reported as a TTY (editor and app terminals often are not);
|
|
272
|
+
// only --yes means "never prompt".
|
|
273
|
+
if (!key && !values.yes) key = ctx.tty ? await readSecret(ctx, "Tempel API key ZyRouter (sk-zyr-...)") : (await ask("Tempel API key ZyRouter (sk-zyr-...): ")).trim();
|
|
274
|
+
if (!key) throw new Error("API key belum diisi. Salin perintah lengkap dari dashboard ZyRouter, atau pakai --key sk-zyr-...");
|
|
275
|
+
if (!KEY_PATTERN.test(key)) throw new Error("API key tidak valid. Salin perintah lengkap dari dashboard ZyRouter, atau pakai --key sk-zyr-...");
|
|
276
|
+
|
|
277
|
+
if (!ctx.tty) out(`ZyRouter installer · key ${maskKey(key)}`);
|
|
278
|
+
const listed = await withSpinner(ctx, "Memeriksa API key...", () => fetchModels(ctx, baseUrl, key));
|
|
279
|
+
if (listed.invalidKey) throw new Error("API key ditolak (401). Cek key di dashboard ZyRouter tab API.");
|
|
280
|
+
const models = listed.ok ? listed.models : FALLBACK_MODELS;
|
|
281
|
+
if (listed.ok) ok(`API key valid · ${models.length} model tersedia${ctx.tty ? ` ${s.gray(maskKey(key))}` : ""}`);
|
|
282
|
+
else warn("Daftar model belum bisa diambil; memakai daftar bawaan.");
|
|
283
|
+
|
|
284
|
+
// 2. Tools.
|
|
285
|
+
if (ctx.tty) step(2, totalSteps, "Aplikasi");
|
|
286
|
+
const tools = toolCatalog(ctx);
|
|
287
|
+
const detected = tools.filter((tool) => tool.detected());
|
|
288
|
+
const preselected = detected.filter((tool) => !tool.optIn);
|
|
289
|
+
const toolNote = (tool) => (!detected.includes(tool) ? "tidak terdeteksi" : tool.optIn ? "terdeteksi · pilih kalau mau dialihkan ke ZyRouter" : "terdeteksi");
|
|
290
|
+
let selected;
|
|
291
|
+
if (values.tools) {
|
|
292
|
+
const wanted = parseList(values.tools);
|
|
293
|
+
selected = tools.filter((tool) => wanted.includes(tool.id));
|
|
294
|
+
const unknown = wanted.filter((id) => !tools.some((tool) => tool.id === id));
|
|
295
|
+
if (unknown.length) throw new Error(`Aplikasi tidak dikenal: ${unknown.join(", ")}. Pilihan: ${tools.map((tool) => tool.id).join(", ")}`);
|
|
296
|
+
} else if (canAsk && ctx.tty) {
|
|
297
|
+
const ids = await pickFromMenu(ctx, {
|
|
298
|
+
title: "Pilih aplikasi yang mau diatur",
|
|
299
|
+
hint: "↑↓ pindah · spasi centang · a semua · enter lanjut",
|
|
300
|
+
multi: true,
|
|
301
|
+
rows: tools.map((tool) => ({ label: tool.name, value: tool.id, note: toolNote(tool), checked: preselected.includes(tool) })),
|
|
302
|
+
});
|
|
303
|
+
selected = tools.filter((tool) => ids.includes(tool.id));
|
|
304
|
+
} else if (canAsk) {
|
|
305
|
+
out("\nAplikasi yang bisa diatur otomatis:");
|
|
306
|
+
tools.forEach((tool, index) => {
|
|
307
|
+
const note = !detected.includes(tool) ? " (tidak terdeteksi)" : tool.optIn ? " (terdeteksi; pilih nomornya kalau mau dialihkan ke ZyRouter)" : "";
|
|
308
|
+
out(` ${index + 1}. [${preselected.includes(tool) ? "x" : " "}] ${tool.name}${note}`);
|
|
309
|
+
});
|
|
310
|
+
const answer = (await ask(`Pilih nomor, pisahkan koma (Enter = ${preselected.length ? "yang dicentang" : "lewati"}): `)).trim();
|
|
311
|
+
selected = answer ? answer.split(",").map((n) => tools[Number(n.trim()) - 1]).filter(Boolean) : preselected;
|
|
312
|
+
} else {
|
|
313
|
+
selected = preselected;
|
|
314
|
+
}
|
|
315
|
+
if (!selected.length) {
|
|
316
|
+
out("Tidak ada aplikasi yang dipilih. Pasang dulu Codex/OpenCode/OpenClaw/Hermes, atau pilih langsung dengan --tools codex,claude-code.");
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
if (ctx.tty) ok(`Aplikasi: ${selected.map((tool) => s.bold(tool.name)).join(", ")}`);
|
|
320
|
+
|
|
321
|
+
// 3. Default model, grouped by family.
|
|
322
|
+
if (ctx.tty) step(3, totalSteps, "Model default");
|
|
323
|
+
const families = groupModels(models);
|
|
324
|
+
const ordered = families.flatMap((group) => group.models);
|
|
325
|
+
let model = values.model || "";
|
|
326
|
+
if (!model && canAsk && ctx.tty) {
|
|
327
|
+
const numbered = new Map(ordered.map((id, index) => [id, index + 1]));
|
|
328
|
+
model = await pickFromMenu(ctx, {
|
|
329
|
+
title: "Pilih model default",
|
|
330
|
+
hint: "↑↓ pindah · enter pilih",
|
|
331
|
+
initialValue: models[0],
|
|
332
|
+
rows: families.flatMap((group) => [{ header: `${group.family} Family` }, ...group.models.map((id) => ({ label: `[${numbered.get(id)}] ${id}`, value: id }))]),
|
|
333
|
+
});
|
|
334
|
+
} else if (!model && canAsk) {
|
|
335
|
+
out("\nModel default:");
|
|
336
|
+
let index = 0;
|
|
337
|
+
for (const group of families) {
|
|
338
|
+
out(`${group.family} Family`);
|
|
339
|
+
group.models.forEach((id) => out(` [${++index}] ${id}`));
|
|
340
|
+
}
|
|
341
|
+
const answer = (await ask(`Pilih nomor (Enter = ${models[0]}): `)).trim();
|
|
342
|
+
model = (answer && ordered[Number(answer) - 1]) || models[0];
|
|
343
|
+
}
|
|
344
|
+
model ||= models[0];
|
|
345
|
+
if (listed.ok && !models.includes(model)) throw new Error(`Model ${model} tidak tersedia untuk key ini.`);
|
|
346
|
+
if (ctx.tty) ok(`Model: ${s.bold(model)}`);
|
|
347
|
+
|
|
348
|
+
if (ctx.dryRun) {
|
|
349
|
+
out("\nRencana (dry run, tidak ada file yang diubah):");
|
|
350
|
+
selected.forEach((tool) => out(` • ${tool.name} → ${tool.file}`));
|
|
351
|
+
if (selected.some((tool) => tool.needsEnv)) out(` • ${ENV_KEY} disimpan untuk terminal baru`);
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 4. Write configs.
|
|
356
|
+
if (ctx.tty) step(4, totalSteps, "Menulis konfigurasi");
|
|
357
|
+
const backup = createBackup(ctx);
|
|
358
|
+
const opts = { baseUrl, anthropicBaseUrl, key, model, models };
|
|
359
|
+
if (!ctx.tty) out("");
|
|
360
|
+
let failures = 0;
|
|
361
|
+
for (const tool of selected) {
|
|
362
|
+
const result = tool.apply(opts, backup);
|
|
363
|
+
if (result.ok) ok(`${tool.name} → ${s.gray(tool.file)}`);
|
|
364
|
+
else {
|
|
365
|
+
failures += 1;
|
|
366
|
+
fail(`${tool.name}: ${result.reason}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (selected.some((tool) => tool.needsEnv) && values["no-env"]) {
|
|
370
|
+
warn(`${ENV_KEY} tidak disimpan (--no-env). Set sendiri sebelum menjalankan Codex/OpenCode.`);
|
|
371
|
+
} else if (selected.some((tool) => tool.needsEnv)) {
|
|
372
|
+
const env = persistApiKey(key, ctx, backup);
|
|
373
|
+
if (env.ok) ok(`${ENV_KEY} disimpan (${env.where})${env.replaced ? "; nilai lamanya disimpan di backup dan dikembalikan saat uninstall" : ""}`);
|
|
374
|
+
else {
|
|
375
|
+
fail(`Gagal menyimpan ${ENV_KEY}; set manual lalu buka terminal baru.`);
|
|
376
|
+
failures += 1;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ZyRouter rejects a request whose x-api-key and Bearer token differ, which happens when Claude Code
|
|
381
|
+
// also picks up an Anthropic key from the environment.
|
|
382
|
+
if (selected.some((tool) => tool.id === "claude-code") && ctx.env.ANTHROPIC_API_KEY) {
|
|
383
|
+
warn("ANTHROPIC_API_KEY masih ada di environment. Hapus dulu supaya Claude Code tidak mengirim dua key berbeda.");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const manual = MANUAL_TOOLS.filter((tool) => tool.detected(ctx));
|
|
387
|
+
if (manual.length) {
|
|
388
|
+
out(`\n${s.bold("Atur manual")} ${s.gray("(nilai di bawah, key ada di dashboard):")}`);
|
|
389
|
+
manual.forEach((tool) => out(` ${s.cyan("•")} ${s.bold(tool.name)}: ${tool.hint} · Base URL ${baseUrl} · model ${model}`));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 5. One tiny request per protocol the chosen tools actually use.
|
|
393
|
+
if (!values["no-test"]) {
|
|
394
|
+
const protocols = [...new Set(selected.map((tool) => tool.protocol))];
|
|
395
|
+
if (ctx.tty) step(5, totalSteps, `Tes koneksi ${s.gray(`(${protocols.length} request kecil ke ${model})`)}`);
|
|
396
|
+
else out(`\nTes koneksi (${protocols.length} request kecil ke ${model})...`);
|
|
397
|
+
for (const protocol of protocols) {
|
|
398
|
+
const test = await withSpinner(ctx, `Menguji ${PROTOCOL_TESTS[protocol].label}...`, () => testProtocol(ctx, protocol, opts));
|
|
399
|
+
const hint = protocol === "messages" && test.status === 400 && /anthropic-compatible/i.test(test.message || "")
|
|
400
|
+
? `Model ${model} belum dibuka untuk Claude Code; jalankan lagi dengan --model lain.`
|
|
401
|
+
: "Cek saldo dan model.";
|
|
402
|
+
if (test.ok) ok(`${test.label}: ${test.text || "OK"}`);
|
|
403
|
+
else {
|
|
404
|
+
fail(`${test.label} gagal (${test.status || test.error}${test.message ? `: ${test.message}` : ""}). ${hint}`);
|
|
405
|
+
failures += 1;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const closing = [
|
|
411
|
+
failures ? s.yellow(s.bold(`Selesai dengan ${failures} catatan di atas`)) : s.green(s.bold("Selesai! ZyRouter siap dipakai")),
|
|
412
|
+
`Backup konfigurasi lama: ${backup.dir}`,
|
|
413
|
+
"Tutup lalu buka lagi terminal, VS Code, dan aplikasinya supaya konfigurasi baru terbaca.",
|
|
414
|
+
"Batalkan kapan saja: npx -y zyrouter@latest uninstall",
|
|
415
|
+
];
|
|
416
|
+
if (ctx.tty) {
|
|
417
|
+
out("");
|
|
418
|
+
box(s, closing, failures ? s.yellow : s.green).forEach((line) => out(line));
|
|
419
|
+
} else {
|
|
420
|
+
out(`\nBackup konfigurasi lama: ${backup.dir}`);
|
|
421
|
+
out("Selesai. Tutup lalu buka lagi terminal, VS Code, dan aplikasinya supaya konfigurasi baru terbaca.");
|
|
422
|
+
out("Batalkan kapan saja: npx -y zyrouter@latest uninstall");
|
|
423
|
+
}
|
|
424
|
+
return failures ? 2 : 0;
|
|
425
|
+
}
|