zyrouter 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,8 +15,9 @@ to `~/.zyrouter/backups/`, writes the ZyRouter settings, and sends one small tes
15
15
  | Claude Code | `~/.claude/settings.json` |
16
16
  | OpenCode | `~/.config/opencode/opencode.json` and `ZYROUTER_API_KEY` |
17
17
  | OpenClaw | `~/.openclaw/openclaw.json` |
18
+ | Hermes Agent | `~/.hermes/config.yaml` (the `model:` block) and `OPENAI_API_KEY` in `~/.hermes/.env` |
18
19
 
19
- Cline, Continue and Hermes Agent are detected but stay manual; the installer prints the values to use.
20
+ Cline and Continue are detected but stay manual; the installer prints the values to use.
20
21
 
21
22
  Undo everything:
22
23
 
@@ -24,5 +25,5 @@ Undo everything:
24
25
  npx -y zyrouter@latest uninstall
25
26
  ```
26
27
 
27
- Options: `--model <id>`, `--tools codex,claude-code,opencode,openclaw`, `--yes`, `--no-test`, `--dry-run`.
28
+ Options: `--model <id>`, `--tools codex,claude-code,opencode,openclaw,hermes`, `--yes`, `--no-test`, `--dry-run`.
28
29
  No dependencies; needs Node.js 18.17 or newer.
package/lib/configs.mjs CHANGED
@@ -112,3 +112,36 @@ export function maskKey(key) {
112
112
  const value = String(key || "");
113
113
  return value.length > 12 ? `${value.slice(0, 10)}…${value.slice(-4)}` : "sk-zyr-…";
114
114
  }
115
+
116
+ /**
117
+ * Hermes Agent ~/.hermes/config.yaml: replaces only the top-level `model:` block (the shape the dashboard
118
+ * guide uses) and leaves every other key untouched.
119
+ */
120
+ export function mergeHermesConfig(existing, { baseUrl, model }) {
121
+ assertSafeValue("base URL", baseUrl);
122
+ assertSafeValue("model", model);
123
+ const block = ["model:", ` default: "${model}"`, ' provider: "custom"', ` base_url: "${baseUrl}"`];
124
+ const lines = String(existing || "").replace(/\r\n/g, "\n").split("\n");
125
+ const start = lines.findIndex((line) => /^model\s*:/.test(line));
126
+ if (start === -1) {
127
+ const rest = lines.join("\n").replace(/^\n+/, "").replace(/\n*$/, "");
128
+ return `${block.join("\n")}\n${rest ? `\n${rest}\n` : ""}`;
129
+ }
130
+ let end = start + 1;
131
+ // The block runs while lines are indented, blank, or comments that belong to it.
132
+ while (end < lines.length && (lines[end].trim() === "" || /^[ \t]/.test(lines[end]))) end += 1;
133
+ while (end > start + 1 && lines[end - 1].trim() === "") end -= 1;
134
+ const merged = [...lines.slice(0, start), ...block, ...lines.slice(end)].join("\n").replace(/\n*$/, "\n");
135
+ return merged;
136
+ }
137
+
138
+ /** Hermes Agent ~/.hermes/.env: sets OPENAI_API_KEY, keeping the other variables. */
139
+ export function setDotenvValue(existing, name, value) {
140
+ assertSafeValue(name, value);
141
+ const lines = String(existing || "").replace(/\r\n/g, "\n").split("\n").filter((line, index, all) => !(index === all.length - 1 && line === ""));
142
+ const pattern = new RegExp(`^\s*(export\s+)?${name}\s*=`);
143
+ const index = lines.findIndex((line) => pattern.test(line));
144
+ if (index === -1) lines.push(`${name}=${value}`);
145
+ else lines[index] = `${name}=${value}`;
146
+ return `${lines.join("\n")}\n`;
147
+ }
package/lib/main.mjs CHANGED
@@ -1,15 +1,25 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
+ import fs from "node:fs";
3
4
  import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
4
6
  import { parseArgs } from "node:util";
5
- import { ENV_KEY, maskKey, mergeClaudeSettings, mergeCodexToml, mergeOpenClaw, mergeOpenCode } from "./configs.mjs";
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";
6
9
  import { createBackup, defaultRun, exists, hasEditorExtension, onPath, persistApiKey, readJson, readText, removePersistedApiKey, restoreBackups, restoreWindowsUserEnv, writeText } from "./system.mjs";
7
10
 
8
11
  export const DEFAULT_BASE_URL = "https://zyrouter.com/v1";
9
12
  const FALLBACK_MODELS = ["gpt-6-astra", "gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-sol", "gpt-6-luna"];
10
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
+ })();
11
21
 
12
- const HELP = `ZyRouter installer — hubungkan Codex, Claude Code, OpenCode, dan OpenClaw ke ZyRouter.
22
+ const HELP = `ZyRouter installer — hubungkan Codex, Claude Code, OpenCode, OpenClaw, dan Hermes Agent ke ZyRouter.
13
23
 
14
24
  Pakai:
15
25
  npx -y zyrouter@latest --key sk-zyr-... pasang (deteksi aplikasi otomatis)
@@ -18,7 +28,7 @@ Pakai:
18
28
  Opsi:
19
29
  --key <key> API key ZyRouter (atau env ZYROUTER_API_KEY, atau ditanya)
20
30
  --model <id> model default, mis. gpt-6.1-terra
21
- --tools <list> codex,claude-code,opencode,openclaw (default: yang terdeteksi)
31
+ --tools <list> codex,claude-code,opencode,openclaw,hermes (default: yang terdeteksi)
22
32
  --yes tanpa pertanyaan: pakai aplikasi yang terdeteksi dan model pertama
23
33
  --no-test lewati request tes di akhir
24
34
  --no-env jangan simpan ZYROUTER_API_KEY ke environment (untuk tes)
@@ -34,6 +44,8 @@ function toolCatalog(ctx) {
34
44
  id: "codex",
35
45
  name: "Codex (CLI & VS Code)",
36
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,
37
49
  file: path.join(home, ".codex", "config.toml"),
38
50
  detected: () => onPath("codex", ctx) || exists(path.join(home, ".codex")) || hasEditorExtension("openai.chatgpt", ctx),
39
51
  apply(opts, backup) {
@@ -77,13 +89,27 @@ function toolCatalog(ctx) {
77
89
  return writeJsonConfig(this.file, (data) => mergeOpenClaw(data, opts), backup);
78
90
  },
79
91
  },
92
+ {
93
+ id: "hermes",
94
+ name: "Hermes Agent",
95
+ protocol: "chat",
96
+ file: path.join(home, ".hermes", "config.yaml"),
97
+ envFile: path.join(home, ".hermes", ".env"),
98
+ detected: () => onPath("hermes", ctx) || exists(path.join(home, ".hermes")),
99
+ apply(opts, backup) {
100
+ backup.record(this.file);
101
+ backup.record(this.envFile);
102
+ writeText(this.file, mergeHermesConfig(readText(this.file), opts));
103
+ writeText(this.envFile, setDotenvValue(readText(this.envFile), "OPENAI_API_KEY", opts.key));
104
+ return { ok: true };
105
+ },
106
+ },
80
107
  ];
81
108
  }
82
109
 
83
110
  const MANUAL_TOOLS = [
84
111
  { id: "cline", name: "Cline", detected: (ctx) => hasEditorExtension("saoudrizwan.claude-dev", ctx), hint: "Settings Cline > API Provider: OpenAI Compatible" },
85
112
  { id: "continue", name: "Continue", detected: (ctx) => hasEditorExtension("continue.continue", ctx) || exists(path.join(ctx.home, ".continue")), hint: "~/.continue/config.yaml, provider openai" },
86
- { id: "hermes", name: "Hermes Agent", detected: (ctx) => onPath("hermes", ctx) || exists(path.join(ctx.home, ".hermes")), hint: "~/.hermes/config.yaml + OPENAI_API_KEY di ~/.hermes/.env" },
87
113
  ];
88
114
 
89
115
  function writeJsonConfig(file, merge, backup) {
@@ -151,6 +177,7 @@ function parseList(value) {
151
177
  }
152
178
 
153
179
  export async function run(argv, overrides = {}) {
180
+ const stdinTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
154
181
  const ctx = {
155
182
  env: process.env,
156
183
  platform: process.platform,
@@ -159,9 +186,12 @@ export async function run(argv, overrides = {}) {
159
186
  run: defaultRun,
160
187
  out: (line = "") => process.stdout.write(`${line}\n`),
161
188
  ask: null,
162
- interactive: Boolean(process.stdin.isTTY),
189
+ interactive: stdinTty,
163
190
  ...overrides,
164
191
  };
192
+ // Arrow-key menus, spinner and colors only on a real terminal; tests and pipes get plain text.
193
+ ctx.tty = overrides.tty ?? (!overrides.out && !ctx.ask && ctx.interactive && stdinTty);
194
+ ctx.style = createStyle(overrides.color ?? (ctx.tty && !ctx.env.NO_COLOR));
165
195
  const { values, positionals } = parseArgs({
166
196
  args: argv,
167
197
  allowPositionals: true,
@@ -178,146 +208,209 @@ export async function run(argv, overrides = {}) {
178
208
  },
179
209
  });
180
210
  const out = ctx.out;
211
+ const s = ctx.style;
212
+ const ok = (text) => out(`${s.green("✔")} ${text}`);
213
+ const warn = (text) => out(`${s.yellow("!")} ${text}`);
214
+ const fail = (text) => out(`${s.red("✖")} ${text}`);
215
+ const step = (index, total, text) => out(`\n${s.orange(s.bold(`${index}/${total}`))} ${s.bold(text)}`);
181
216
  if (values.help) {
182
217
  out(HELP);
183
218
  return 0;
184
219
  }
185
220
  ctx.dryRun = values["dry-run"] === true;
186
221
 
187
- let rl = null;
222
+ // One readline interface per question, closed right away, so raw-mode menus own stdin afterwards.
188
223
  const ask = ctx.ask || (async (question) => {
189
- rl ||= createInterface({ input: process.stdin, output: process.stdout });
190
- return rl.question(question);
224
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
225
+ try {
226
+ return await rl.question(question);
227
+ } finally {
228
+ rl.close();
229
+ }
191
230
  });
192
- const canAsk = !values.yes && (ctx.ask || ctx.interactive);
231
+ const canAsk = !values.yes && Boolean(ctx.ask || ctx.interactive);
193
232
 
194
- try {
195
- if (positionals[0] === "uninstall") {
196
- const result = restoreBackups(ctx);
197
- // Windows: put back the value from before the first install. Elsewhere the rc files were restored already.
198
- if (ctx.platform === "win32") {
199
- if (result.windowsEnv) restoreWindowsUserEnv(ctx, result.windowsEnvPrevious);
200
- } else {
201
- removePersistedApiKey(ctx);
202
- }
203
- out(result.sessions ? `✔ ${result.files} file dikembalikan dari ${result.sessions} backup.` : "Tidak ada backup ZyRouter untuk dikembalikan.");
204
- out("Tutup lalu buka lagi terminal dan editor supaya perubahan terbaca.");
205
- return 0;
233
+ if (positionals[0] === "uninstall") {
234
+ const result = restoreBackups(ctx);
235
+ // Windows: put back the value from before the first install. Elsewhere the rc files were restored already.
236
+ if (ctx.platform === "win32") {
237
+ if (result.windowsEnv) restoreWindowsUserEnv(ctx, result.windowsEnvPrevious);
238
+ } else {
239
+ removePersistedApiKey(ctx);
206
240
  }
241
+ if (result.sessions) ok(`${result.files} file dikembalikan dari ${result.sessions} backup.`);
242
+ else out("Tidak ada backup ZyRouter untuk dikembalikan.");
243
+ out("Tutup lalu buka lagi terminal dan editor supaya perubahan terbaca.");
244
+ return 0;
245
+ }
207
246
 
208
- const baseUrl = String(values["base-url"] || ctx.env.ZYROUTER_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
209
- 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).");
210
- const anthropicBaseUrl = baseUrl.replace(/\/v1$/i, "");
247
+ if (ctx.tty) {
248
+ out("");
249
+ 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));
250
+ }
211
251
 
212
- // An existing ZYROUTER_API_KEY is only reused when it is a ZyRouter key; other tools use the name too.
213
- const envKey = ctx.env[ENV_KEY] || "";
214
- let key = values.key || (KEY_PATTERN.test(envKey) ? envKey : "");
215
- if (!values.key && envKey && !key) out(`! ${ENV_KEY} di environment bukan key ZyRouter; diabaikan.`);
216
- // Ask for a missing key even when stdin is not reported as a TTY (editor and app terminals often are not);
217
- // only --yes means "never prompt".
218
- if (!key && !values.yes) key = (await ask("Tempel API key ZyRouter (sk-zyr-...): ")).trim();
219
- if (!key) throw new Error("API key belum diisi. Salin perintah lengkap dari dashboard ZyRouter, atau pakai --key sk-zyr-...");
220
- if (!KEY_PATTERN.test(key)) throw new Error("API key tidak valid. Salin perintah lengkap dari dashboard ZyRouter, atau pakai --key sk-zyr-...");
252
+ const baseUrl = String(values["base-url"] || ctx.env.ZYROUTER_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
253
+ 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).");
254
+ const anthropicBaseUrl = baseUrl.replace(/\/v1$/i, "");
255
+ const totalSteps = values["no-test"] ? 4 : 5;
221
256
 
222
- out(`ZyRouter installer · key ${maskKey(key)}`);
223
- const listed = await fetchModels(ctx, baseUrl, key);
224
- if (listed.invalidKey) throw new Error("API key ditolak (401). Cek key di dashboard ZyRouter tab API.");
225
- const models = listed.ok ? listed.models : FALLBACK_MODELS;
226
- out(listed.ok ? `✔ API key valid · ${models.length} model tersedia` : "! Daftar model belum bisa diambil; memakai daftar bawaan.");
257
+ // 1. API key. An existing ZYROUTER_API_KEY is only reused when it is a ZyRouter key; other tools use the name too.
258
+ if (ctx.tty) step(1, totalSteps, "API key");
259
+ const envKey = ctx.env[ENV_KEY] || "";
260
+ let key = values.key || (KEY_PATTERN.test(envKey) ? envKey : "");
261
+ if (!values.key && envKey && !key) warn(`${ENV_KEY} di environment bukan key ZyRouter; diabaikan.`);
262
+ // Ask for a missing key even when stdin is not reported as a TTY (editor and app terminals often are not);
263
+ // only --yes means "never prompt".
264
+ 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();
265
+ if (!key) throw new Error("API key belum diisi. Salin perintah lengkap dari dashboard ZyRouter, atau pakai --key sk-zyr-...");
266
+ if (!KEY_PATTERN.test(key)) throw new Error("API key tidak valid. Salin perintah lengkap dari dashboard ZyRouter, atau pakai --key sk-zyr-...");
227
267
 
228
- const tools = toolCatalog(ctx);
229
- const detected = tools.filter((tool) => tool.detected());
230
- const preselected = detected.filter((tool) => !tool.optIn);
231
- let selected;
232
- if (values.tools) {
233
- const wanted = parseList(values.tools);
234
- selected = tools.filter((tool) => wanted.includes(tool.id));
235
- const unknown = wanted.filter((id) => !tools.some((tool) => tool.id === id));
236
- if (unknown.length) throw new Error(`Aplikasi tidak dikenal: ${unknown.join(", ")}. Pilihan: ${tools.map((tool) => tool.id).join(", ")}`);
237
- } else if (canAsk) {
238
- out("\nAplikasi yang bisa diatur otomatis:");
239
- tools.forEach((tool, index) => {
240
- const note = !detected.includes(tool) ? " (tidak terdeteksi)" : tool.optIn ? " (terdeteksi; pilih nomornya kalau mau dialihkan ke ZyRouter)" : "";
241
- out(` ${index + 1}. [${preselected.includes(tool) ? "x" : " "}] ${tool.name}${note}`);
242
- });
243
- const answer = (await ask(`Pilih nomor, pisahkan koma (Enter = ${preselected.length ? "yang dicentang" : "lewati"}): `)).trim();
244
- selected = answer ? answer.split(",").map((n) => tools[Number(n.trim()) - 1]).filter(Boolean) : preselected;
245
- } else {
246
- selected = preselected;
247
- }
248
- if (!selected.length) {
249
- out("Tidak ada aplikasi yang dipilih. Pasang dulu Codex/OpenCode/OpenClaw, atau pilih langsung dengan --tools codex,claude-code.");
250
- return 1;
251
- }
268
+ if (!ctx.tty) out(`ZyRouter installer · key ${maskKey(key)}`);
269
+ const listed = await withSpinner(ctx, "Memeriksa API key...", () => fetchModels(ctx, baseUrl, key));
270
+ if (listed.invalidKey) throw new Error("API key ditolak (401). Cek key di dashboard ZyRouter tab API.");
271
+ const models = listed.ok ? listed.models : FALLBACK_MODELS;
272
+ if (listed.ok) ok(`API key valid · ${models.length} model tersedia${ctx.tty ? ` ${s.gray(maskKey(key))}` : ""}`);
273
+ else warn("Daftar model belum bisa diambil; memakai daftar bawaan.");
252
274
 
253
- let model = values.model || "";
254
- if (!model && canAsk) {
255
- out("\nModel default:");
256
- models.forEach((id, index) => out(` ${index + 1}. ${id}`));
257
- const answer = (await ask(`Pilih nomor (Enter = ${models[0]}): `)).trim();
258
- model = (answer && models[Number(answer) - 1]) || models[0];
259
- }
260
- model ||= models[0];
261
- if (listed.ok && !models.includes(model)) throw new Error(`Model ${model} tidak tersedia untuk key ini.`);
275
+ // 2. Tools.
276
+ if (ctx.tty) step(2, totalSteps, "Aplikasi");
277
+ const tools = toolCatalog(ctx);
278
+ const detected = tools.filter((tool) => tool.detected());
279
+ const preselected = detected.filter((tool) => !tool.optIn);
280
+ const toolNote = (tool) => (!detected.includes(tool) ? "tidak terdeteksi" : tool.optIn ? "terdeteksi · pilih kalau mau dialihkan ke ZyRouter" : "terdeteksi");
281
+ let selected;
282
+ if (values.tools) {
283
+ const wanted = parseList(values.tools);
284
+ selected = tools.filter((tool) => wanted.includes(tool.id));
285
+ const unknown = wanted.filter((id) => !tools.some((tool) => tool.id === id));
286
+ if (unknown.length) throw new Error(`Aplikasi tidak dikenal: ${unknown.join(", ")}. Pilihan: ${tools.map((tool) => tool.id).join(", ")}`);
287
+ } else if (canAsk && ctx.tty) {
288
+ const ids = await pickFromMenu(ctx, {
289
+ title: "Pilih aplikasi yang mau diatur",
290
+ hint: "↑↓ pindah · spasi centang · a semua · enter lanjut",
291
+ multi: true,
292
+ rows: tools.map((tool) => ({ label: tool.name, value: tool.id, note: toolNote(tool), checked: preselected.includes(tool) })),
293
+ });
294
+ selected = tools.filter((tool) => ids.includes(tool.id));
295
+ } else if (canAsk) {
296
+ out("\nAplikasi yang bisa diatur otomatis:");
297
+ tools.forEach((tool, index) => {
298
+ const note = !detected.includes(tool) ? " (tidak terdeteksi)" : tool.optIn ? " (terdeteksi; pilih nomornya kalau mau dialihkan ke ZyRouter)" : "";
299
+ out(` ${index + 1}. [${preselected.includes(tool) ? "x" : " "}] ${tool.name}${note}`);
300
+ });
301
+ const answer = (await ask(`Pilih nomor, pisahkan koma (Enter = ${preselected.length ? "yang dicentang" : "lewati"}): `)).trim();
302
+ selected = answer ? answer.split(",").map((n) => tools[Number(n.trim()) - 1]).filter(Boolean) : preselected;
303
+ } else {
304
+ selected = preselected;
305
+ }
306
+ if (!selected.length) {
307
+ out("Tidak ada aplikasi yang dipilih. Pasang dulu Codex/OpenCode/OpenClaw/Hermes, atau pilih langsung dengan --tools codex,claude-code.");
308
+ return 1;
309
+ }
310
+ if (ctx.tty) ok(`Aplikasi: ${selected.map((tool) => s.bold(tool.name)).join(", ")}`);
262
311
 
263
- if (ctx.dryRun) {
264
- out("\nRencana (dry run, tidak ada file yang diubah):");
265
- selected.forEach((tool) => out(` • ${tool.name} → ${tool.file}`));
266
- if (selected.some((tool) => tool.needsEnv)) out(` • ${ENV_KEY} disimpan untuk terminal baru`);
267
- return 0;
312
+ // 3. Default model, grouped by family.
313
+ if (ctx.tty) step(3, totalSteps, "Model default");
314
+ const families = groupModels(models);
315
+ const ordered = families.flatMap((group) => group.models);
316
+ let model = values.model || "";
317
+ if (!model && canAsk && ctx.tty) {
318
+ const numbered = new Map(ordered.map((id, index) => [id, index + 1]));
319
+ model = await pickFromMenu(ctx, {
320
+ title: "Pilih model default",
321
+ hint: "↑↓ pindah · enter pilih",
322
+ initialValue: models[0],
323
+ rows: families.flatMap((group) => [{ header: `${group.family} Family` }, ...group.models.map((id) => ({ label: `[${numbered.get(id)}] ${id}`, value: id }))]),
324
+ });
325
+ } else if (!model && canAsk) {
326
+ out("\nModel default:");
327
+ let index = 0;
328
+ for (const group of families) {
329
+ out(`${group.family} Family`);
330
+ group.models.forEach((id) => out(` [${++index}] ${id}`));
268
331
  }
332
+ const answer = (await ask(`Pilih nomor (Enter = ${models[0]}): `)).trim();
333
+ model = (answer && ordered[Number(answer) - 1]) || models[0];
334
+ }
335
+ model ||= models[0];
336
+ if (listed.ok && !models.includes(model)) throw new Error(`Model ${model} tidak tersedia untuk key ini.`);
337
+ if (ctx.tty) ok(`Model: ${s.bold(model)}`);
269
338
 
270
- const backup = createBackup(ctx);
271
- const opts = { baseUrl, anthropicBaseUrl, key, model, models };
272
- out("");
273
- let failures = 0;
274
- for (const tool of selected) {
275
- const result = tool.apply(opts, backup);
276
- if (result.ok) out(`✔ ${tool.name} → ${tool.file}`);
277
- else {
278
- failures += 1;
279
- out(`✖ ${tool.name}: ${result.reason}`);
280
- }
339
+ if (ctx.dryRun) {
340
+ out("\nRencana (dry run, tidak ada file yang diubah):");
341
+ selected.forEach((tool) => out(` • ${tool.name} → ${tool.file}`));
342
+ if (selected.some((tool) => tool.needsEnv)) out(` • ${ENV_KEY} disimpan untuk terminal baru`);
343
+ return 0;
344
+ }
345
+
346
+ // 4. Write configs.
347
+ if (ctx.tty) step(4, totalSteps, "Menulis konfigurasi");
348
+ const backup = createBackup(ctx);
349
+ const opts = { baseUrl, anthropicBaseUrl, key, model, models };
350
+ if (!ctx.tty) out("");
351
+ let failures = 0;
352
+ for (const tool of selected) {
353
+ const result = tool.apply(opts, backup);
354
+ if (result.ok) ok(`${tool.name} → ${s.gray(tool.file)}`);
355
+ else {
356
+ failures += 1;
357
+ fail(`${tool.name}: ${result.reason}`);
281
358
  }
282
- if (selected.some((tool) => tool.needsEnv) && values["no-env"]) {
283
- out(`! ${ENV_KEY} tidak disimpan (--no-env). Set sendiri sebelum menjalankan Codex/OpenCode.`);
284
- } else if (selected.some((tool) => tool.needsEnv)) {
285
- const env = persistApiKey(key, ctx, backup);
286
- out(env.ok ? `✔ ${ENV_KEY} disimpan (${env.where})${env.replaced ? "; nilai lamanya disimpan di backup dan dikembalikan saat uninstall" : ""}` : `✖ Gagal menyimpan ${ENV_KEY}; set manual lalu buka terminal baru.`);
287
- if (!env.ok) failures += 1;
359
+ }
360
+ if (selected.some((tool) => tool.needsEnv) && values["no-env"]) {
361
+ warn(`${ENV_KEY} tidak disimpan (--no-env). Set sendiri sebelum menjalankan Codex/OpenCode.`);
362
+ } else if (selected.some((tool) => tool.needsEnv)) {
363
+ const env = persistApiKey(key, ctx, backup);
364
+ if (env.ok) ok(`${ENV_KEY} disimpan (${env.where})${env.replaced ? "; nilai lamanya disimpan di backup dan dikembalikan saat uninstall" : ""}`);
365
+ else {
366
+ fail(`Gagal menyimpan ${ENV_KEY}; set manual lalu buka terminal baru.`);
367
+ failures += 1;
288
368
  }
369
+ }
289
370
 
290
- // ZyRouter rejects a request whose x-api-key and Bearer token differ, which happens when Claude Code
291
- // also picks up an Anthropic key from the environment.
292
- if (selected.some((tool) => tool.id === "claude-code") && ctx.env.ANTHROPIC_API_KEY) {
293
- out("! ANTHROPIC_API_KEY masih ada di environment. Hapus dulu supaya Claude Code tidak mengirim dua key berbeda.");
294
- }
371
+ // ZyRouter rejects a request whose x-api-key and Bearer token differ, which happens when Claude Code
372
+ // also picks up an Anthropic key from the environment.
373
+ if (selected.some((tool) => tool.id === "claude-code") && ctx.env.ANTHROPIC_API_KEY) {
374
+ warn("ANTHROPIC_API_KEY masih ada di environment. Hapus dulu supaya Claude Code tidak mengirim dua key berbeda.");
375
+ }
295
376
 
296
- const manual = MANUAL_TOOLS.filter((tool) => tool.detected(ctx));
297
- if (manual.length) {
298
- out("\nAtur manual (nilai di bawah, key ada di dashboard):");
299
- manual.forEach((tool) => out(` • ${tool.name}: ${tool.hint} · Base URL ${baseUrl} · model ${model}`));
300
- }
377
+ const manual = MANUAL_TOOLS.filter((tool) => tool.detected(ctx));
378
+ if (manual.length) {
379
+ out(`\n${s.bold("Atur manual")} ${s.gray("(nilai di bawah, key ada di dashboard):")}`);
380
+ manual.forEach((tool) => out(` ${s.cyan("•")} ${s.bold(tool.name)}: ${tool.hint} · Base URL ${baseUrl} · model ${model}`));
381
+ }
301
382
 
302
- if (!values["no-test"]) {
303
- // One tiny request per protocol the chosen tools actually use.
304
- const protocols = [...new Set(selected.map((tool) => tool.protocol))];
305
- out(`\nTes koneksi (${protocols.length} request kecil ke ${model})...`);
306
- for (const protocol of protocols) {
307
- const test = await testProtocol(ctx, protocol, opts);
308
- const hint = protocol === "messages" && test.status === 400 && /anthropic-compatible/i.test(test.message || "")
309
- ? `Model ${model} belum dibuka untuk Claude Code; jalankan lagi dengan --model lain.`
310
- : "Cek saldo dan model.";
311
- out(test.ok ? `✔ ${test.label}: ${test.text || "OK"}` : `✖ ${test.label} gagal (${test.status || test.error}${test.message ? `: ${test.message}` : ""}). ${hint}`);
312
- if (!test.ok) failures += 1;
383
+ // 5. One tiny request per protocol the chosen tools actually use.
384
+ if (!values["no-test"]) {
385
+ const protocols = [...new Set(selected.map((tool) => tool.protocol))];
386
+ if (ctx.tty) step(5, totalSteps, `Tes koneksi ${s.gray(`(${protocols.length} request kecil ke ${model})`)}`);
387
+ else out(`\nTes koneksi (${protocols.length} request kecil ke ${model})...`);
388
+ for (const protocol of protocols) {
389
+ const test = await withSpinner(ctx, `Menguji ${PROTOCOL_TESTS[protocol].label}...`, () => testProtocol(ctx, protocol, opts));
390
+ const hint = protocol === "messages" && test.status === 400 && /anthropic-compatible/i.test(test.message || "")
391
+ ? `Model ${model} belum dibuka untuk Claude Code; jalankan lagi dengan --model lain.`
392
+ : "Cek saldo dan model.";
393
+ if (test.ok) ok(`${test.label}: ${test.text || "OK"}`);
394
+ else {
395
+ fail(`${test.label} gagal (${test.status || test.error}${test.message ? `: ${test.message}` : ""}). ${hint}`);
396
+ failures += 1;
313
397
  }
314
398
  }
399
+ }
315
400
 
401
+ const closing = [
402
+ failures ? s.yellow(s.bold(`Selesai dengan ${failures} catatan di atas`)) : s.green(s.bold("Selesai! ZyRouter siap dipakai")),
403
+ `Backup konfigurasi lama: ${backup.dir}`,
404
+ "Tutup lalu buka lagi terminal, VS Code, dan aplikasinya supaya konfigurasi baru terbaca.",
405
+ "Batalkan kapan saja: npx -y zyrouter@latest uninstall",
406
+ ];
407
+ if (ctx.tty) {
408
+ out("");
409
+ box(s, closing, failures ? s.yellow : s.green).forEach((line) => out(line));
410
+ } else {
316
411
  out(`\nBackup konfigurasi lama: ${backup.dir}`);
317
412
  out("Selesai. Tutup lalu buka lagi terminal, VS Code, dan aplikasinya supaya konfigurasi baru terbaca.");
318
413
  out("Batalkan kapan saja: npx -y zyrouter@latest uninstall");
319
- return failures ? 2 : 0;
320
- } finally {
321
- rl?.close();
322
414
  }
415
+ return failures ? 2 : 0;
323
416
  }
package/lib/ui.mjs ADDED
@@ -0,0 +1,173 @@
1
+ // Terminal UI without dependencies: colors, a spinner, arrow-key menus and masked input.
2
+ // Interactive pieces need a real TTY; callers fall back to numbered prompts everywhere else.
3
+
4
+ import readline from "node:readline";
5
+
6
+ export function createStyle(enabled) {
7
+ const wrap = (open, close) => (text) => (enabled ? `\x1b[${open}m${text}\x1b[${close}m` : String(text));
8
+ return {
9
+ bold: wrap(1, 22),
10
+ dim: wrap(2, 22),
11
+ red: wrap(31, 39),
12
+ green: wrap(32, 39),
13
+ yellow: wrap(33, 39),
14
+ cyan: wrap(36, 39),
15
+ magenta: wrap(35, 39),
16
+ gray: wrap(90, 39),
17
+ orange: wrap("38;5;208", 39),
18
+ inverse: wrap(7, 27),
19
+ };
20
+ }
21
+
22
+ const FAMILIES = [
23
+ { name: "OpenAI", match: /^(gpt|o\d|chatgpt|codex)/i },
24
+ { name: "Anthropic", match: /^claude/i },
25
+ { name: "Google", match: /gemini/i },
26
+ { name: "DeepSeek", match: /deepseek/i },
27
+ { name: "Moonshot", match: /kimi/i },
28
+ { name: "Zhipu", match: /glm/i },
29
+ { name: "Alibaba", match: /qwen/i },
30
+ { name: "MiniMax", match: /minimax/i },
31
+ { name: "Xiaomi", match: /mimo/i },
32
+ ];
33
+
34
+ export function modelFamily(id) {
35
+ return FAMILIES.find((family) => family.match.test(id))?.name ?? "Other";
36
+ }
37
+
38
+ /** Models grouped by family, keeping the catalog order inside each group and of the groups themselves. */
39
+ export function groupModels(models) {
40
+ const groups = new Map();
41
+ for (const id of models) {
42
+ const family = modelFamily(id);
43
+ if (!groups.has(family)) groups.set(family, []);
44
+ groups.get(family).push(id);
45
+ }
46
+ const other = groups.get("Other");
47
+ groups.delete("Other");
48
+ if (other) groups.set("Other", other);
49
+ return [...groups].map(([family, ids]) => ({ family, models: ids }));
50
+ }
51
+
52
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
53
+
54
+ /** Spinner on one line while `task` runs; without a TTY it just runs the task. */
55
+ export async function withSpinner(ctx, text, task) {
56
+ if (!ctx.tty) return task();
57
+ const { style } = ctx;
58
+ let frame = 0;
59
+ const draw = () => process.stdout.write(`\r\x1b[2K${style.cyan(SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length])} ${text}`);
60
+ draw();
61
+ const timer = setInterval(draw, 80);
62
+ try {
63
+ return await task();
64
+ } finally {
65
+ clearInterval(timer);
66
+ process.stdout.write("\r\x1b[2K");
67
+ }
68
+ }
69
+
70
+ function withRawInput(handler) {
71
+ return new Promise((resolve, reject) => {
72
+ const input = process.stdin;
73
+ readline.emitKeypressEvents(input);
74
+ const wasRaw = input.isRaw;
75
+ input.setRawMode(true);
76
+ input.resume();
77
+ const finish = (error, value) => {
78
+ input.off("keypress", onKey);
79
+ input.setRawMode(wasRaw);
80
+ input.pause();
81
+ if (error) reject(error);
82
+ else resolve(value);
83
+ };
84
+ const onKey = (text, key = {}) => {
85
+ if ((key.ctrl && key.name === "c") || key.name === "escape") return finish(new Error("Dibatalkan."));
86
+ handler(text, key, (value) => finish(null, value));
87
+ };
88
+ input.on("keypress", onKey);
89
+ });
90
+ }
91
+
92
+ /** Masked input: shows • per character, supports paste and backspace. */
93
+ export async function readSecret(ctx, label) {
94
+ const { style } = ctx;
95
+ let value = "";
96
+ const draw = () => process.stdout.write(`\r\x1b[2K${style.cyan("?")} ${style.bold(label)} ${style.gray("•".repeat(Math.min(value.length, 48)))}`);
97
+ draw();
98
+ const result = await withRawInput((text, key, done) => {
99
+ if (key.name === "return" || key.name === "enter") return done(value);
100
+ if (key.name === "backspace") value = value.slice(0, -1);
101
+ else if (text && !key.ctrl && !key.meta) value += text.replace(/[\r\n]/g, "");
102
+ draw();
103
+ });
104
+ process.stdout.write("\n");
105
+ return result.trim();
106
+ }
107
+
108
+ /**
109
+ * Arrow-key menu. `rows` mixes { header } lines with { label, value, note, checked } items.
110
+ * Single choice returns a value; multi choice returns the checked values.
111
+ */
112
+ export async function pickFromMenu(ctx, { title, hint, rows, multi = false, initialValue }) {
113
+ const { style } = ctx;
114
+ const items = rows.filter((row) => !row.header);
115
+ let cursor = Math.max(0, items.findIndex((item) => item.value === initialValue));
116
+ const checked = new Set(items.filter((item) => item.checked).map((item) => item.value));
117
+ let drawn = 0;
118
+ process.stdout.write("\x1b[?25l");
119
+
120
+ const render = () => {
121
+ const height = Math.max(8, (process.stdout.rows || 24) - 5);
122
+ const current = items[cursor];
123
+ const currentRow = rows.indexOf(current);
124
+ const start = Math.max(0, Math.min(currentRow - Math.floor(height / 2), rows.length - height));
125
+ const visible = rows.slice(start, start + height);
126
+ const lines = [`${style.cyan("?")} ${style.bold(title)} ${style.gray(hint)}`];
127
+ if (start > 0) lines.push(style.gray(" ↑ lainnya"));
128
+ for (const row of visible) {
129
+ if (row.header) {
130
+ lines.push(` ${style.orange(style.bold(row.header))}`);
131
+ continue;
132
+ }
133
+ const active = row === current;
134
+ const pointer = active ? style.cyan("❯") : " ";
135
+ const box = multi ? (checked.has(row.value) ? style.green("◉") : style.gray("◯")) + " " : "";
136
+ const label = active ? style.cyan(style.bold(row.label)) : row.label;
137
+ lines.push(` ${pointer} ${box}${label}${row.note ? ` ${style.gray(row.note)}` : ""}`);
138
+ }
139
+ if (start + height < rows.length) lines.push(style.gray(" ↓ lainnya"));
140
+ if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[0J`);
141
+ process.stdout.write(`${lines.join("\n")}\n`);
142
+ drawn = lines.length;
143
+ };
144
+
145
+ render();
146
+ try {
147
+ return await withRawInput((text, key, done) => {
148
+ if (key.name === "up" || key.name === "k") cursor = (cursor - 1 + items.length) % items.length;
149
+ else if (key.name === "down" || key.name === "j") cursor = (cursor + 1) % items.length;
150
+ else if (multi && key.name === "space") {
151
+ const value = items[cursor].value;
152
+ if (checked.has(value)) checked.delete(value);
153
+ else checked.add(value);
154
+ } else if (multi && key.name === "a") {
155
+ if (checked.size === items.length) checked.clear();
156
+ else items.forEach((item) => checked.add(item.value));
157
+ } else if (key.name === "return" || key.name === "enter") {
158
+ return done(multi ? items.filter((item) => checked.has(item.value)).map((item) => item.value) : items[cursor].value);
159
+ }
160
+ render();
161
+ });
162
+ } finally {
163
+ if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[0J`);
164
+ process.stdout.write("\x1b[?25h");
165
+ }
166
+ }
167
+
168
+ /** A rounded box around a few lines, sized to the longest one (ANSI codes excluded). */
169
+ export function box(style, lines, color = style.gray) {
170
+ const width = Math.max(...lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").length));
171
+ const pad = (line) => line + " ".repeat(width - line.replace(/\x1b\[[0-9;]*m/g, "").length);
172
+ return [color(`╭${"─".repeat(width + 2)}╮`), ...lines.map((line) => `${color("│")} ${pad(line)} ${color("│")}`), color(`╰${"─".repeat(width + 2)}╯`)];
173
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zyrouter",
3
- "version": "0.1.0",
4
- "description": "Connect Codex, Claude Code, OpenCode and OpenClaw to ZyRouter with one command.",
3
+ "version": "0.1.2",
4
+ "description": "Connect Codex, Claude Code, OpenCode, OpenClaw and Hermes Agent to ZyRouter with one command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "zyrouter": "bin/cli.mjs"