pleumrouter 0.3.1 → 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 CHANGED
@@ -1,22 +1,20 @@
1
1
  // Ollama 0.31 런처풍 시작 메뉴 — bare `pleum`(TTY)에서 뜬다. 비TTY면 false 반환(호출자가 HELP 폴백).
2
2
  // 동작 패리티(ollama cmd/tui/tui.go·cmd/launch 소스 분석 기반):
3
3
  // · 인라인 렌더(대체 화면 아님), ▸ 커서 + dim 설명 2줄 항목, 하단 키 안내
4
- // · "더 보기…"는 커서가 닿는 순간 그 자리에서 펼쳐지고, pinned 구간으로 올라가면 접힘
5
4
  // · →(configure) = 모델 피커를 강제로 띄우고 선택 즉시 실행(+ launch_models에 저장)
6
5
  // · Enter = 저장된 모델(launch_models)로 바로 실행, 미설치면 Y/n 확인 후 자동 설치
7
6
  // · 실행 종료 후 메뉴로 복귀(ollama runInteractiveTUI 루프), last_selection 커서 복원
8
- // · 키: ↑/k ↓/j · enter/space 실행 · →/l 모델 설정 · esc/q/ctrl+c 종료
9
- // 마우스(ollama엔 없음 — SGR 1002/1003/1006 직접 구현):
10
- // · 호버=하이라이트 · 좌클릭=바로 실행 · 우클릭=모델 설정 · 휠=이동
11
- // · 인라인 렌더라 프레임 상단 행이 가변 — 렌더마다 DSR \x1b[6n으로 끝 행을 얻어 좌표 매핑
7
+ // · 키보드 전용: ↑/k ↓/j 이동 · enter/space 실행 · →/l 모델 설정 · esc/q 뒤로(최상위에선 종료) · ctrl+c 즉시 종료
8
+ // 에이전트는 최상위에 나열하지 않고 "에이전트 실행" 한 항목으로 묶어 서브메뉴(agentSelect)로 진입한다 —
9
+ // 지원 에이전트가 많아질수록 최상위 목록이 늘어지는 것을 막기 위함(runList는 두 메뉴가 공유하는 렌더 루프).
12
10
 
13
11
  import { readFileSync } from "node:fs";
14
12
  import { spawn } from "node:child_process";
15
13
  import { createInterface } from "node:readline";
16
14
  import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
17
- import { keyFeeder, select, setMouse, lineIndexAt } from "./interactive.mjs";
15
+ import { keyFeeder, select } from "./interactive.mjs";
18
16
  import { readConfig, writeConfig } from "./config.mjs";
19
- import { RECIPES } from "./recipes.mjs";
17
+ import { RECIPES, MANUAL } from "./recipes.mjs";
20
18
  import { onPath, launch } from "./launch.mjs";
21
19
  import { fetchModels, matchModels, listModels } from "./models.mjs";
22
20
  import { run } from "./run.mjs";
@@ -25,51 +23,69 @@ import { top } from "./top.mjs";
25
23
  import { usage } from "./usage.mjs";
26
24
  import { balance } from "./balance.mjs";
27
25
  import { health } from "./health.mjs";
26
+ import { t } from "./i18n.mjs";
28
27
 
29
- const TOOL_DESC = {
30
- claude: "Anthropic 코딩 에이전트 — 서브에이전트 지원",
31
- codex: "OpenAI 코딩 에이전트",
32
- aider: "터미널 페어 프로그래밍",
33
- goose: "Block의 로컬 우선 에이전트",
34
- openhands: "자율 개발 에이전트",
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"),
35
39
  };
36
40
 
37
41
  const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
38
42
 
39
- // 메뉴 항목 빌드(순수 함수 — 테스트 대상). tool이 있는 항목만 →(모델 설정) 가능.
40
- export function buildItems({ loggedIn, installed, savedModels = {}, expanded = false }) {
43
+ // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 에이전트는 없음 — "agents" 한 항목으로 서브메뉴 진입.
44
+ export function buildItems({ loggedIn, savedModels = {} }) {
41
45
  const items = [];
42
- if (!loggedIn) items.push({ id: "login", title: "로그인", desc: "브라우저로 로그인하고 API 키를 저장합니다" });
46
+ if (!loggedIn) items.push({ id: "login", title: t("Log in"), desc: t("Log in via browser and save the API key") });
43
47
  items.push({
44
48
  id: "chat",
45
49
  tool: "chat",
46
- title: "모델과 채팅",
47
- desc: `대화형 채팅을 시작합니다${savedModels.chat ? ` · 모델: ${savedModels.chat}` : ""}`,
50
+ title: t("Chat with a model"),
51
+ desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
48
52
  });
49
- const tools = expanded ? Object.keys(RECIPES) : ["claude", "codex"];
50
- for (const t of tools) {
51
- const r = RECIPES[t];
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)) {
52
68
  items.push({
53
- id: `launch:${t}`,
54
- tool: t,
55
- title: `${r.label} 실행${installed[t] ? "" : autoInstallable(r) ? " (설치)" : " (미설치)"}`,
56
- desc: installed[t]
57
- ? `${TOOL_DESC[t]}${savedModels[t] ? ` · 모델: ${savedModels[t]}` : ""}`
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]}` : ""}`
58
74
  : autoInstallable(r)
59
- ? `Enter로 바로 설치 — ${r.install}`
60
- : `수동 설치: ${hyperlink(r.install)}`,
75
+ ? `${t("Press Enter to install now")} — ${r.install}`
76
+ : `${t("Manual install:")} ${hyperlink(r.install)}`,
61
77
  });
62
78
  }
63
- if (expanded) {
64
- items.push(
65
- { id: "models", title: "모델 목록", desc: "전체 모델 — 가격·컨텍스트 포함" },
66
- { id: "top", title: "리더보드", desc: "모델 랭킹·제공자 점유율" },
67
- { id: "usage", title: "지출 리포트", desc: "최근 30일 내 지출" },
68
- { id: "balance", title: "잔액", desc: "크레딧 잔액 조회" },
69
- { id: "health", title: "상태 점검", desc: "게이트웨이 연결·로그인 상태" },
70
- );
71
- } else {
72
- items.push({ id: "more", title: "더 보기…", desc: "다른 에이전트 · 모델 목록 · 잔액 · 리더보드" });
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
+ });
73
89
  }
74
90
  return items;
75
91
  }
@@ -93,11 +109,11 @@ async function pickModelFor(item) {
93
109
  try {
94
110
  models = await fetchModels();
95
111
  } catch (e) {
96
- console.log(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
112
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
97
113
  return undefined;
98
114
  }
99
115
  const m = await select(models, {
100
- message: `${item.title.replace(/\s+\((설치|미설치)\)/, "")} — 모델 선택`,
116
+ message: `${item.title} — ${t("select a model")}`,
101
117
  formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
102
118
  matches: matchModels,
103
119
  limit: 12,
@@ -108,14 +124,14 @@ async function pickModelFor(item) {
108
124
  // 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
109
125
  async function ensureInstalled(r) {
110
126
  if (!autoInstallable(r)) {
111
- console.log(`${r.label}는 수동 설치가 필요합니다:\n ${style.bold(hyperlink(r.install))}`);
127
+ console.log(`${t("{label} needs manual installation:", { label: r.label })}\n ${style.bold(hyperlink(r.install))}`);
112
128
  return false;
113
129
  }
114
130
  const rl = createInterface({ input: process.stdin, output: process.stdout });
115
- const ans = await new Promise((res) => rl.question(`${r.label}가 설치되어 있지 않습니다. 지금 설치할까요? [Y/n] `, res));
131
+ const ans = await new Promise((res) => rl.question(t("{label} isn't installed. Install it now? [Y/n] ", { label: r.label }), res));
116
132
  rl.close();
117
133
  if (/^n/i.test(ans.trim())) {
118
- console.log(`설치 취소 — 수동 설치: ${r.install}`);
134
+ console.log(t("Install canceled — manual install: {install}", { install: r.install }));
119
135
  return false;
120
136
  }
121
137
  console.log(style.dim(`$ ${r.install}`));
@@ -125,59 +141,22 @@ async function ensureInstalled(r) {
125
141
  c.on("error", () => res(1));
126
142
  });
127
143
  if (code !== 0) {
128
- console.log(`${style.red("✗")} 설치 실패 (exit ${code})`);
144
+ console.log(`${style.red("✗")} ${t("Install failed (exit {code})", { code })}`);
129
145
  return false;
130
146
  }
131
- console.log(`${style.green("✓")} ${r.label} 설치 완료`);
147
+ console.log(`${style.green("✓")} ${t("{label} installed", { label: r.label })}`);
132
148
  return true;
133
149
  }
134
150
 
135
- // 클릭 y좌표 → 항목 인덱스(순수 함수 — 테스트 대상).
136
- // 프레임 구조: [헤더, 공백, 항목별 2줄…, 공백, 푸터]. 항목 밖(헤더/공백/푸터)이면 -1.
137
- export function itemIndexAt(y, cprRow, lines, itemCount, rowsFor) {
138
- const li = lineIndexAt(y, cprRow, lines, rowsFor);
139
- if (li < 2) return -1;
140
- const idx = (li - 2) >> 1;
141
- return idx < itemCount ? idx : -1;
142
- }
143
-
144
- // 화살표+마우스 메뉴 루프 — {item, model?} 또는 null(Esc) 반환. 비TTY면 undefined.
145
- async function menuSelect() {
151
+ // 화살표 메뉴 루프(키보드 전용) — {item, model?} · "quit"(ctrl+c) · null(esc/q) 반환. 비TTY면 undefined.
152
+ // 메인 메뉴·에이전트 서브메뉴가 공유하는 렌더 루프 — header/footer/초기 커서만 다르다.
153
+ async function runList(items, { header, footer, initialCursor = 0 } = {}) {
146
154
  const input = process.stdin;
147
155
  const output = process.stdout;
148
156
  if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
149
157
 
150
- const installed = Object.fromEntries(Object.entries(RECIPES).map(([t, r]) => [t, onPath(r.bin)]));
151
- const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
152
- let expanded = false;
153
- const rebuild = () => {
154
- const cfg = readConfig();
155
- return buildItems({ loggedIn: Boolean(cfg?.key), installed, savedModels: cfg?.launch_models ?? {}, expanded });
156
- };
157
-
158
- let items = rebuild();
159
- const pinnedCount = items.length - 1; // "더 보기…" 자리 — 여기 닿으면 펼침, 위로 벗어나면 접힘
160
- let cursor = 0;
161
-
162
- // last_selection 커서 복원 — 더 보기 그룹에 있었으면 펼친 채 시작(ollama shouldExpandOthers).
163
- const last = readConfig()?.last_selection;
164
- if (last) {
165
- let idx = items.findIndex((it) => it.id === last);
166
- if (idx < 0) {
167
- expanded = true;
168
- items = rebuild();
169
- idx = items.findIndex((it) => it.id === last);
170
- if (idx < 0) {
171
- expanded = false;
172
- items = rebuild();
173
- }
174
- }
175
- if (idx >= 0) cursor = idx;
176
- }
177
-
158
+ let cursor = Math.max(0, Math.min(initialCursor, items.length - 1));
178
159
  let lastRows = 0;
179
- let lastLines = [];
180
- let cprRow = 0;
181
160
 
182
161
  const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
183
162
  const erase = () => {
@@ -187,7 +166,7 @@ async function menuSelect() {
187
166
  };
188
167
  const render = () => {
189
168
  const lines = [
190
- `${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
169
+ header,
191
170
  "",
192
171
  ...items.flatMap((it, i) =>
193
172
  i === cursor
@@ -195,38 +174,20 @@ async function menuSelect() {
195
174
  : [` ${it.title}`, ` ${style.dim(it.desc)}`],
196
175
  ),
197
176
  "",
198
- style.dim("↑/↓·휠 이동 • enter·클릭 실행 • →·우클릭 모델 설정 • esc 종료"),
177
+ style.dim(footer),
199
178
  ];
200
179
  erase();
201
180
  output.write(lines.join("\n"));
202
181
  lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
203
- lastLines = lines;
204
- output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
205
182
  };
206
-
207
- const moveTo = (next) => {
208
- if (!expanded && next === pinnedCount) {
209
- expanded = true; // "더 보기…"에 커서가 닿으면 그 자리에서 펼침
210
- items = rebuild();
211
- cursor = next;
212
- return;
213
- }
214
- if (expanded && next < pinnedCount) {
215
- expanded = false; // pinned 구간으로 돌아가면 접힘
216
- items = rebuild();
217
- cursor = next;
218
- return;
219
- }
220
- cursor = next;
183
+ const move = (dir) => {
184
+ cursor = Math.max(0, Math.min(items.length - 1, cursor + dir));
221
185
  };
222
- const move = (dir) => moveTo(Math.max(0, Math.min(items.length - 1, cursor + dir)));
223
- const itemAt = (y) => itemIndexAt(y, cprRow, lastLines, items.length, rowsFor);
224
186
 
225
187
  return new Promise((resolve) => {
226
188
  const suspend = () => {
227
189
  input.off("data", onData);
228
190
  input.setRawMode(false);
229
- setMouse(output, false);
230
191
  output.write("\x1b[?25h");
231
192
  erase();
232
193
  };
@@ -234,7 +195,6 @@ async function menuSelect() {
234
195
  input.setRawMode(true);
235
196
  input.resume();
236
197
  output.write("\x1b[?25l");
237
- setMouse(output, true);
238
198
  input.on("data", onData);
239
199
  render();
240
200
  };
@@ -243,7 +203,7 @@ async function menuSelect() {
243
203
  input.pause();
244
204
  resolve(value);
245
205
  };
246
- // 모델 셀렉터는 자체 raw-mode·마우스 관리 — 메뉴 입력을 내려놓고 넘긴다.
206
+ // 모델 셀렉터는 자체 raw-mode 관리 — 메뉴 입력을 내려놓고 넘긴다.
247
207
  // 선택되면 ollama처럼 즉시 실행으로 이어가고, 취소면 메뉴 복귀.
248
208
  const configure = async (it) => {
249
209
  suspend();
@@ -258,42 +218,18 @@ async function menuSelect() {
258
218
 
259
219
  const feed = keyFeeder();
260
220
  const onData = async (chunk) => {
261
- let dirty = false; // CPR만 온 턴에 재렌더하면 렌더→CPR 질의→응답 무한 루프
221
+ let dirty = false;
262
222
  for (const key of feed(chunk)) {
263
- if (key.type === "ctrlc" || key.type === "escape" || (key.type === "char" && key.value === "q")) {
264
- return finish(null);
265
- }
266
- if (key.type === "cpr") {
267
- cprRow = key.row;
268
- continue;
269
- }
223
+ if (key.type === "ctrlc") return finish("quit"); // 어느 메뉴에 있든 즉시 전체 종료
224
+ if (key.type === "escape" || (key.type === "char" && key.value === "q")) return finish(null); // 서브메뉴면 상위로, 최상위면 종료
270
225
  if (key.type === "enter" || (key.type === "char" && key.value === " ")) {
271
226
  return finish({ item: items[cursor] });
272
227
  }
273
- if (key.type === "mouse") {
274
- if (key.wheel === "up") move(-1);
275
- else if (key.wheel === "down") move(1);
276
- else if (key.motion) {
277
- const idx = itemAt(key.y);
278
- if (idx < 0 || idx === cursor) continue;
279
- moveTo(idx);
280
- } else if (!key.release && key.btn === 0) {
281
- const idx = itemAt(key.y);
282
- if (idx < 0) continue;
283
- const wasMore = !expanded && items[idx]?.id === "more";
284
- moveTo(idx);
285
- if (!wasMore) return finish({ item: items[cursor] });
286
- } else if (!key.release && key.btn === 2) {
287
- const idx = itemAt(key.y);
288
- if (idx < 0 || !items[idx].tool) continue;
289
- moveTo(idx);
290
- return void (await configure(items[cursor]));
291
- } else continue;
292
- } else if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
228
+ if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
293
229
  else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
294
230
  else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
295
231
  const it = items[cursor];
296
- if (!it.tool) continue;
232
+ if (!it.tool || it.manual) continue; // 도구 없는 항목·수동 설정 항목은 모델 설정 불가
297
233
  return void (await configure(it));
298
234
  } else continue;
299
235
  dirty = true;
@@ -305,19 +241,42 @@ async function menuSelect() {
305
241
  input.setRawMode(true);
306
242
  input.resume();
307
243
  output.write("\x1b[?25l");
308
- setMouse(output, true);
309
244
  input.on("data", onData);
310
245
  render();
311
246
  });
312
247
  }
313
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
+
314
273
  // bare `pleum` 진입점. 비TTY면 false(호출자가 HELP 출력), 그 외 true.
315
- // ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·Esc 종료 제외).
274
+ // ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·ctrl+c 종료 제외).
316
275
  export async function mainMenu() {
317
276
  for (;;) {
318
277
  const picked = await menuSelect();
319
278
  if (picked === undefined) return false; // 비TTY
320
- if (picked === null) return true; // Esc — ollama처럼 조용히 종료
279
+ if (picked === null || picked === "quit") return true; // esc/q/ctrl+c — ollama처럼 조용히 종료
321
280
 
322
281
  const { item, model } = picked;
323
282
  if (item.id !== "login") saveLastSelection(item.id);
@@ -348,10 +307,18 @@ export async function mainMenu() {
348
307
  case "health":
349
308
  await health();
350
309
  continue;
351
- default: {
352
- const r = RECIPES[item.tool];
353
- if (!onPath(r.bin) && !(await ensureInstalled(r))) continue;
354
- await launch(item.tool, model ?? saved[item.tool], [], { exit: false });
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 });
355
322
  console.log(); // 에이전트 출력과 다음 메뉴 사이 여백
356
323
  continue; // ollama처럼 에이전트 종료 후 메뉴 복귀
357
324
  }
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": {