pleumrouter 0.3.0 → 0.4.0

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/src/menu.mjs ADDED
@@ -0,0 +1,327 @@
1
+ // Ollama 0.31 런처풍 시작 메뉴 — bare `pleum`(TTY)에서 뜬다. 비TTY면 false 반환(호출자가 HELP 폴백).
2
+ // 동작 패리티(ollama cmd/tui/tui.go·cmd/launch 소스 분석 기반):
3
+ // · 인라인 렌더(대체 화면 아님), ▸ 커서 + dim 설명 2줄 항목, 하단 키 안내
4
+ // · →(configure) = 모델 피커를 강제로 띄우고 선택 즉시 실행(+ launch_models에 저장)
5
+ // · Enter = 저장된 모델(launch_models)로 바로 실행, 미설치면 Y/n 확인 후 자동 설치
6
+ // · 실행 종료 후 메뉴로 복귀(ollama runInteractiveTUI 루프), last_selection 커서 복원
7
+ // · 키보드 전용: ↑/k ↓/j 이동 · enter/space 실행 · →/l 모델 설정 · esc/q 뒤로(최상위에선 종료) · ctrl+c 즉시 종료
8
+ // 에이전트는 최상위에 나열하지 않고 "에이전트 실행" 한 항목으로 묶어 서브메뉴(agentSelect)로 진입한다 —
9
+ // 지원 에이전트가 많아질수록 최상위 목록이 늘어지는 것을 막기 위함(runList는 두 메뉴가 공유하는 렌더 루프).
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { spawn } from "node:child_process";
13
+ import { createInterface } from "node:readline";
14
+ import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
15
+ import { keyFeeder, select } from "./interactive.mjs";
16
+ import { readConfig, writeConfig } from "./config.mjs";
17
+ import { RECIPES, MANUAL } from "./recipes.mjs";
18
+ import { onPath, launch } from "./launch.mjs";
19
+ import { fetchModels, matchModels, listModels } from "./models.mjs";
20
+ import { run } from "./run.mjs";
21
+ import { login } from "./login.mjs";
22
+ import { top } from "./top.mjs";
23
+ import { usage } from "./usage.mjs";
24
+ import { balance } from "./balance.mjs";
25
+ import { health } from "./health.mjs";
26
+ import { t } from "./i18n.mjs";
27
+
28
+ const AGENT_DESC = {
29
+ claude: t("Anthropic's coding agent — supports subagents"),
30
+ codex: t("OpenAI's coding agent"),
31
+ aider: t("Terminal pair programming"),
32
+ goose: t("Block's local-first agent"),
33
+ openhands: t("Autonomous dev agent"),
34
+ opencode: t("Multi-provider terminal coding agent"),
35
+ omp: t("Bun-based coding agent — Claude routing only"),
36
+ hermes: t("Nous Research's general-purpose agent"),
37
+ qwen: t("Alibaba's terminal coding agent"),
38
+ copilot: t("GitHub's terminal coding agent"),
39
+ };
40
+
41
+ const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
42
+
43
+ // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 에이전트는 없음 — "agents" 한 항목으로 서브메뉴 진입.
44
+ export function buildItems({ loggedIn, savedModels = {} }) {
45
+ const items = [];
46
+ if (!loggedIn) items.push({ id: "login", title: t("Log in"), desc: t("Log in via browser and save the API key") });
47
+ items.push({
48
+ id: "chat",
49
+ tool: "chat",
50
+ title: t("Chat with a model"),
51
+ desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
52
+ });
53
+ items.push({ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") });
54
+ items.push(
55
+ { id: "models", title: t("Model list"), desc: t("All models — price and context included") },
56
+ { id: "top", title: t("Leaderboard"), desc: t("Model rankings · provider share") },
57
+ { id: "usage", title: t("Usage report"), desc: t("Spend over the last 30 days") },
58
+ { id: "balance", title: t("Balance"), desc: t("Check your credit balance") },
59
+ { id: "health", title: t("Health check"), desc: t("Gateway connectivity · login status") },
60
+ );
61
+ return items;
62
+ }
63
+
64
+ // 에이전트 서브메뉴 항목(순수 함수 — 테스트 대상). RECIPES(자동 실행) + MANUAL(설정 안내만) 전부 포함.
65
+ export function buildAgentItems({ installed, savedModels = {} }) {
66
+ const items = [];
67
+ for (const [tool, r] of Object.entries(RECIPES)) {
68
+ items.push({
69
+ id: `launch:${tool}`,
70
+ tool,
71
+ title: `${r.label}${installed[tool] ? "" : autoInstallable(r) ? ` (${t("install")})` : ` (${t("not installed")})`}`,
72
+ desc: installed[tool]
73
+ ? `${AGENT_DESC[tool] ?? ""}${savedModels[tool] ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`
74
+ : autoInstallable(r)
75
+ ? `${t("Press Enter to install now")} — ${r.install}`
76
+ : `${t("Manual install:")} ${hyperlink(r.install)}`,
77
+ });
78
+ }
79
+ for (const [tool, m] of Object.entries(MANUAL)) {
80
+ items.push({
81
+ id: `launch:${tool}`,
82
+ tool,
83
+ manual: true,
84
+ title: `${m.label} (${t("setup required")})`,
85
+ desc: installed[tool]
86
+ ? t("Installed — press Enter for the {file} setup snippet", { file: m.file })
87
+ : `${t("✗ not installed —")} ${m.install}`,
88
+ });
89
+ }
90
+ return items;
91
+ }
92
+
93
+ // 항목별 기본 모델 저장(launch_models). 로그인 전에도 동작 — /v1/models는 공개.
94
+ function saveModel(tool, id) {
95
+ const cfg = readConfig() ?? {};
96
+ cfg.launch_models = { ...cfg.launch_models, [tool]: id };
97
+ writeConfig(cfg);
98
+ }
99
+
100
+ // 마지막 메뉴 선택 저장 — 다음 실행 시 커서 복원(ollama last_selection과 동일).
101
+ function saveLastSelection(id) {
102
+ const cfg = readConfig() ?? {};
103
+ cfg.last_selection = id;
104
+ writeConfig(cfg);
105
+ }
106
+
107
+ async function pickModelFor(item) {
108
+ let models;
109
+ try {
110
+ models = await fetchModels();
111
+ } catch (e) {
112
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
113
+ return undefined;
114
+ }
115
+ const m = await select(models, {
116
+ message: `${item.title} — ${t("select a model")}`,
117
+ formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
118
+ matches: matchModels,
119
+ limit: 12,
120
+ });
121
+ return m?.id;
122
+ }
123
+
124
+ // 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
125
+ async function ensureInstalled(r) {
126
+ if (!autoInstallable(r)) {
127
+ console.log(`${t("{label} needs manual installation:", { label: r.label })}\n ${style.bold(hyperlink(r.install))}`);
128
+ return false;
129
+ }
130
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
131
+ const ans = await new Promise((res) => rl.question(t("{label} isn't installed. Install it now? [Y/n] ", { label: r.label }), res));
132
+ rl.close();
133
+ if (/^n/i.test(ans.trim())) {
134
+ console.log(t("Install canceled — manual install: {install}", { install: r.install }));
135
+ return false;
136
+ }
137
+ console.log(style.dim(`$ ${r.install}`));
138
+ const code = await new Promise((res) => {
139
+ const c = spawn(r.install, { shell: true, stdio: "inherit" });
140
+ c.on("exit", (n) => res(n ?? 1));
141
+ c.on("error", () => res(1));
142
+ });
143
+ if (code !== 0) {
144
+ console.log(`${style.red("✗")} ${t("Install failed (exit {code})", { code })}`);
145
+ return false;
146
+ }
147
+ console.log(`${style.green("✓")} ${t("{label} installed", { label: r.label })}`);
148
+ return true;
149
+ }
150
+
151
+ // 화살표 메뉴 루프(키보드 전용) — {item, model?} · "quit"(ctrl+c) · null(esc/q) 반환. 비TTY면 undefined.
152
+ // 메인 메뉴·에이전트 서브메뉴가 공유하는 렌더 루프 — header/footer/초기 커서만 다르다.
153
+ async function runList(items, { header, footer, initialCursor = 0 } = {}) {
154
+ const input = process.stdin;
155
+ const output = process.stdout;
156
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
157
+
158
+ let cursor = Math.max(0, Math.min(initialCursor, items.length - 1));
159
+ let lastRows = 0;
160
+
161
+ const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
162
+ const erase = () => {
163
+ if (!lastRows) return;
164
+ output.write("\r" + (lastRows > 1 ? `\x1b[${lastRows - 1}A` : "") + "\x1b[0J");
165
+ lastRows = 0;
166
+ };
167
+ const render = () => {
168
+ const lines = [
169
+ header,
170
+ "",
171
+ ...items.flatMap((it, i) =>
172
+ i === cursor
173
+ ? [`${style.plum("▸")} ${style.bold(it.title)}`, ` ${style.dim(it.desc)}`]
174
+ : [` ${it.title}`, ` ${style.dim(it.desc)}`],
175
+ ),
176
+ "",
177
+ style.dim(footer),
178
+ ];
179
+ erase();
180
+ output.write(lines.join("\n"));
181
+ lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
182
+ };
183
+ const move = (dir) => {
184
+ cursor = Math.max(0, Math.min(items.length - 1, cursor + dir));
185
+ };
186
+
187
+ return new Promise((resolve) => {
188
+ const suspend = () => {
189
+ input.off("data", onData);
190
+ input.setRawMode(false);
191
+ output.write("\x1b[?25h");
192
+ erase();
193
+ };
194
+ const resume = () => {
195
+ input.setRawMode(true);
196
+ input.resume();
197
+ output.write("\x1b[?25l");
198
+ input.on("data", onData);
199
+ render();
200
+ };
201
+ const finish = (value) => {
202
+ suspend();
203
+ input.pause();
204
+ resolve(value);
205
+ };
206
+ // 모델 셀렉터는 자체 raw-mode 관리 — 메뉴 입력을 내려놓고 넘긴다.
207
+ // 선택되면 ollama처럼 즉시 실행으로 이어가고, 취소면 메뉴 복귀.
208
+ const configure = async (it) => {
209
+ suspend();
210
+ const picked = await pickModelFor(it);
211
+ if (picked) {
212
+ input.pause();
213
+ resolve({ item: it, model: picked });
214
+ return;
215
+ }
216
+ resume();
217
+ };
218
+
219
+ const feed = keyFeeder();
220
+ const onData = async (chunk) => {
221
+ let dirty = false;
222
+ for (const key of feed(chunk)) {
223
+ if (key.type === "ctrlc") return finish("quit"); // 어느 메뉴에 있든 즉시 전체 종료
224
+ if (key.type === "escape" || (key.type === "char" && key.value === "q")) return finish(null); // 서브메뉴면 상위로, 최상위면 종료
225
+ if (key.type === "enter" || (key.type === "char" && key.value === " ")) {
226
+ return finish({ item: items[cursor] });
227
+ }
228
+ if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
229
+ else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
230
+ else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
231
+ const it = items[cursor];
232
+ if (!it.tool || it.manual) continue; // 도구 없는 항목·수동 설정 항목은 모델 설정 불가
233
+ return void (await configure(it));
234
+ } else continue;
235
+ dirty = true;
236
+ }
237
+ if (dirty) render();
238
+ };
239
+
240
+ input.setEncoding("utf8");
241
+ input.setRawMode(true);
242
+ input.resume();
243
+ output.write("\x1b[?25l");
244
+ input.on("data", onData);
245
+ render();
246
+ });
247
+ }
248
+
249
+ async function menuSelect() {
250
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
251
+ const cfg = readConfig();
252
+ const items = buildItems({ loggedIn: Boolean(cfg?.key), savedModels: cfg?.launch_models ?? {} });
253
+ const last = cfg?.last_selection;
254
+ const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
255
+ return runList(items, {
256
+ header: `${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
257
+ footer: t("↑/↓ move • enter run • → set model • esc quit"),
258
+ initialCursor,
259
+ });
260
+ }
261
+
262
+ async function agentSelect() {
263
+ const installed = Object.fromEntries(
264
+ [...Object.entries(RECIPES), ...Object.entries(MANUAL)].map(([tool, r]) => [tool, onPath(r.bin)]),
265
+ );
266
+ const items = buildAgentItems({ installed, savedModels: readConfig()?.launch_models ?? {} });
267
+ return runList(items, {
268
+ header: style.bold(style.plum(t("Run an agent via PleumRouter"))),
269
+ footer: t("↑/↓ move • enter run • → set model • esc back"),
270
+ });
271
+ }
272
+
273
+ // bare `pleum` 진입점. 비TTY면 false(호출자가 HELP 출력), 그 외 true.
274
+ // ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·ctrl+c 종료 제외).
275
+ export async function mainMenu() {
276
+ for (;;) {
277
+ const picked = await menuSelect();
278
+ if (picked === undefined) return false; // 비TTY
279
+ if (picked === null || picked === "quit") return true; // esc/q/ctrl+c — ollama처럼 조용히 종료
280
+
281
+ const { item, model } = picked;
282
+ if (item.id !== "login") saveLastSelection(item.id);
283
+ if (model && item.tool) saveModel(item.tool, model);
284
+ const saved = readConfig()?.launch_models ?? {};
285
+
286
+ switch (item.id) {
287
+ case "login":
288
+ await login();
289
+ continue; // 로그인 후 메뉴로 복귀
290
+ case "chat": {
291
+ const m = model ?? saved.chat;
292
+ await run(m ? [m] : []); // REPL 종료는 run 내부 process.exit — 메뉴 복귀 없음
293
+ return true;
294
+ }
295
+ case "models":
296
+ await listModels([]);
297
+ continue;
298
+ case "top":
299
+ await top([]);
300
+ continue;
301
+ case "usage":
302
+ await usage([]);
303
+ continue;
304
+ case "balance":
305
+ await balance();
306
+ continue;
307
+ case "health":
308
+ await health();
309
+ continue;
310
+ case "agents": {
311
+ const agentPicked = await agentSelect();
312
+ if (agentPicked === "quit") return true; // 서브메뉴에서 ctrl+c → 전체 종료
313
+ if (!agentPicked) continue; // esc/q — 메인 메뉴로 복귀
314
+ const { item: agentItem, model: agentModel } = agentPicked;
315
+ if (agentModel && !agentItem.manual) saveModel(agentItem.tool, agentModel);
316
+ if (!agentItem.manual) {
317
+ const r = RECIPES[agentItem.tool];
318
+ if (!onPath(r.bin) && !(await ensureInstalled(r))) continue;
319
+ }
320
+ const agentSaved = readConfig()?.launch_models ?? {};
321
+ await launch(agentItem.tool, agentModel ?? agentSaved[agentItem.tool], [], { exit: false });
322
+ console.log(); // 에이전트 출력과 다음 메뉴 사이 여백
323
+ continue; // ollama처럼 에이전트 종료 후 메뉴 복귀
324
+ }
325
+ }
326
+ }
327
+ }
package/src/models.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  import { V1 } from "./config.mjs";
2
2
  import { style, padDisplay, divider } from "./ansi.mjs";
3
+ import { t } from "./i18n.mjs";
3
4
 
4
5
  // GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
5
6
  export async function fetchModels() {
6
7
  const res = await fetch(`${V1}/models`);
7
- if (!res.ok) throw new Error(`모델 목록 조회 실패 (${res.status})`);
8
+ if (!res.ok) throw new Error(t("Failed to fetch model list ({status})", { status: res.status }));
8
9
  const data = await res.json();
9
10
  return data.data ?? [];
10
11
  }
@@ -84,7 +85,7 @@ export async function listModels(rest) {
84
85
  : { json: false, info: false, query: rest };
85
86
  if (info) {
86
87
  if (!query) {
87
- console.error("사용법: pleum models <model-id> --info");
88
+ console.error(t("Usage: pleum models <model-id> --info"));
88
89
  process.exit(1);
89
90
  }
90
91
  return showModelInfo(query, { json });
@@ -96,19 +97,19 @@ export async function listModels(rest) {
96
97
  return;
97
98
  }
98
99
  if (!rows.length) {
99
- console.log("일치하는 모델 없음");
100
+ console.log(t("No matching models"));
100
101
  return;
101
102
  }
102
103
  console.log(formatTable(rows));
103
- console.log(style.dim(`\n${rows.length} models · 가격=토큰 1M당(표시환율 기준, 실청구는 장중 환율로 소폭 상이)`));
104
+ console.log(style.dim(`\n${rows.length} models · ${t("price = per 1M tokens (display rate; actual billing may vary slightly with the intraday rate)")}`));
104
105
  }
105
106
 
106
107
  // ── 모델 상세(ollama show 대응) — GET /v1/models/{id} ────────────────────
107
108
  // id는 provider/model 형식이라 슬래시를 그대로 둔다(백엔드 :path 컨버터).
108
109
  export async function fetchModelDetail(id) {
109
110
  const res = await fetch(`${V1}/models/${id}`);
110
- if (res.status === 404) throw new Error(`모델을 찾을 수 없음: ${id} (목록: pleum models)`);
111
- if (!res.ok) throw new Error(`모델 상세 조회 실패 (${res.status})`);
111
+ if (res.status === 404) throw new Error(t("Model not found: {id} (list: pleum models)", { id }));
112
+ if (!res.ok) throw new Error(t("Failed to fetch model detail ({status})", { status: res.status }));
112
113
  return res.json();
113
114
  }
114
115
 
@@ -125,16 +126,16 @@ export function renderModelInfo(m, styler = style) {
125
126
  ["modality", m.modality || "text"],
126
127
  ["context", fmtCtx(m.context_length || m.context_window)],
127
128
  ["max output", fmtCtx(m.max_output_tokens || m.max_output)],
128
- ["가격/1M", `입력 ${pin} · 출력 ${pout} ${styler.dim(`(마크업 ${m.effective_markup_percent ?? 0}% 포함)`)}`],
129
+ [t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: m.effective_markup_percent ?? 0 }))}`],
129
130
  ];
130
- if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? "강제(끌 수 없음)" : "지원"]);
131
+ if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? t("forced (can't be turned off)") : t("supported")]);
131
132
  for (const [k, v] of facts) lines.push(`${styler.dim(padDisplay(k, 10))} ${v}`);
132
133
 
133
134
  const u = m.usage;
134
135
  if (u) {
135
136
  lines.push(
136
- `${styler.dim(padDisplay("사용량", 10))} 주간 ${fmtNum(u.weekly_calls)} calls · ${fmtNum(u.weekly_tokens)} tok` +
137
- styler.dim(` (누적 ${fmtNum(u.total_calls)} calls)`),
137
+ `${styler.dim(padDisplay(t("usage"), 10))} ${t("weekly")} ${fmtNum(u.weekly_calls)} calls · ${fmtNum(u.weekly_tokens)} tok` +
138
+ styler.dim(` (${t("total")} ${fmtNum(u.total_calls)} calls)`),
138
139
  );
139
140
  }
140
141
 
@@ -161,7 +162,7 @@ export function renderModelInfo(m, styler = style) {
161
162
  const sibs = (m.siblings || []).map((s) => s.id);
162
163
  if (sibs.length) {
163
164
  lines.push("");
164
- lines.push(`${styler.dim("같은 제공자:")} ${sibs.join(styler.dim(" · "))}`);
165
+ lines.push(`${styler.dim(t("Same provider:"))} ${sibs.join(styler.dim(" · "))}`);
165
166
  }
166
167
  return lines.join("\n");
167
168
  }
package/src/recipes.mjs CHANGED
@@ -1,8 +1,13 @@
1
1
  import { ROOT, V1 } from "./config.mjs";
2
+ import { t } from "./i18n.mjs";
2
3
 
3
- // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration.
4
+ // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration + 에이전트별 공식 문서 조사(2026-07).
4
5
  // type "env" → 환경변수만 세팅하고 바로 실행.
5
6
  // type "codex-home" → 격리된 CODEX_HOME에 config.toml을 써서 실행(사용자 ~/.codex 보존).
7
+ //
8
+ // 커스텀 엔드포인트를 지원하지 않아 PleumRouter로 라우팅할 수 없는 도구는 등록하지 않는다(조사 결과, 2026-07):
9
+ // Gemini CLI(구글 백엔드 전용, PR#2899 머지 거부) · Cursor CLI(구독 백엔드 전용, 공식 확인) ·
10
+ // Amp(Sourcegraph 백엔드 전용) · OpenClaw(멀티채널 게이트웨이 데몬 — 세션형 코딩 CLI가 아님 + 파일설정 전용).
6
11
  export const RECIPES = {
7
12
  claude: {
8
13
  label: "Claude Code",
@@ -18,6 +23,14 @@ export const RECIPES = {
18
23
  }),
19
24
  args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
20
25
  },
26
+ codex: {
27
+ label: "Codex CLI",
28
+ bin: "codex",
29
+ type: "codex-home",
30
+ install: "npm i -g @openai/codex",
31
+ env: (key) => ({ PLEUM_API_KEY: key }),
32
+ args: () => [],
33
+ },
21
34
  aider: {
22
35
  label: "Aider",
23
36
  bin: "aider",
@@ -31,7 +44,7 @@ export const RECIPES = {
31
44
  bin: "goose",
32
45
  type: "env",
33
46
  install: "https://block.github.io/goose/docs/getting-started/installation",
34
- note: "Goose는 모델을 설정에서 지정합니다 — `goose configure`에서 openai/<id> 형식 사용.",
47
+ note: t("Goose sets its model in its own config — use the openai/<id> format in `goose configure`."),
35
48
  env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
36
49
  args: () => [],
37
50
  },
@@ -40,16 +53,73 @@ export const RECIPES = {
40
53
  bin: "openhands",
41
54
  type: "env",
42
55
  install: "python -m pip install openhands-ai",
43
- note: "OpenHands는 모델명 앞에 openai/ 접두사를 붙여 설정하세요.",
56
+ note: t("For OpenHands, prefix the model name with openai/ in its config."),
44
57
  env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
45
58
  args: () => [],
46
59
  },
47
- codex: {
48
- label: "Codex CLI",
49
- bin: "codex",
50
- type: "codex-home",
51
- install: "npm i -g @openai/codex",
52
- env: (key) => ({ PLEUM_API_KEY: key }),
60
+ opencode: {
61
+ label: "opencode",
62
+ bin: "opencode",
63
+ type: "env",
64
+ install: "npm i -g opencode-ai@latest",
65
+ // opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
66
+ // 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
67
+ note: t("Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched."),
68
+ env: (key, model) => ({
69
+ PLEUM_API_KEY: key,
70
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
71
+ provider: {
72
+ pleum: {
73
+ npm: "@ai-sdk/openai-compatible",
74
+ name: "PleumRouter",
75
+ options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
76
+ models: { [model || "gpt-4.1"]: {} },
77
+ },
78
+ },
79
+ }),
80
+ }),
81
+ args: (m) => ["--model", `pleum/${m || "gpt-4.1"}`],
82
+ },
83
+ omp: {
84
+ label: "omp (Oh My Pi)",
85
+ bin: "omp",
86
+ type: "env",
87
+ install: "curl -fsSL https://omp.sh/install | sh",
88
+ // omp는 커스텀 OpenAI 호환 엔드포인트는 YAML 설정 파일로만 읽지만, 내장 anthropic
89
+ // provider만은 claude와 동일한 ANTHROPIC_BASE_URL env var로 리디렉션된다.
90
+ note: t("Needs the Bun runtime (bun >= 1.3.14) on PATH. Only Claude-family models are routed via env vars."),
91
+ env: (key) => ({ ANTHROPIC_BASE_URL: ROOT, ANTHROPIC_API_KEY: key }),
92
+ args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
93
+ },
94
+ hermes: {
95
+ label: "Hermes Agent",
96
+ bin: "hermes",
97
+ type: "env",
98
+ install: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
99
+ note: t("Pick the model after it starts with the `hermes model` command."),
100
+ env: (key) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key }),
101
+ args: () => [],
102
+ },
103
+ qwen: {
104
+ label: "Qwen Code",
105
+ bin: "qwen",
106
+ type: "env",
107
+ install: "npm install -g @qwen-code/qwen-code@latest",
108
+ env: (key, model) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key, OPENAI_MODEL: model || "gpt-4.1" }),
109
+ args: () => [],
110
+ },
111
+ copilot: {
112
+ label: "GitHub Copilot CLI",
113
+ bin: "copilot",
114
+ type: "env",
115
+ install: "npm install -g @github/copilot",
116
+ note: t("Needs GitHub Copilot CLI 2026-04 or later for BYOK provider routing support."),
117
+ env: (key, model) => ({
118
+ COPILOT_PROVIDER_BASE_URL: V1,
119
+ COPILOT_PROVIDER_TYPE: "openai",
120
+ COPILOT_PROVIDER_API_KEY: key,
121
+ COPILOT_MODEL: model || "gpt-4.1",
122
+ }),
53
123
  args: () => [],
54
124
  },
55
125
  };
@@ -72,22 +142,10 @@ wire_api = "responses"
72
142
 
73
143
  // provider 정의가 파일에 있어야 하는 도구 — 자동 실행 대신 설정 스니펫만 안내.
74
144
  export const MANUAL = {
75
- opencode: {
76
- label: "OpenCode",
77
- file: "opencode.json",
78
- snippet: () => `{
79
- "provider": {
80
- "pleum": {
81
- "npm": "@ai-sdk/openai-compatible",
82
- "name": "PleumRouter",
83
- "options": { "baseURL": "${V1}", "apiKey": "{env:PLEUM_API_KEY}" },
84
- "models": { "gpt-4.1": {} }
85
- }
86
- }
87
- }`,
88
- },
89
145
  crush: {
90
146
  label: "Crush",
147
+ bin: "crush",
148
+ install: "npm install -g @charmland/crush",
91
149
  file: "crush.json",
92
150
  snippet: () => `{
93
151
  "providers": {