pleumrouter 0.3.1 → 0.5.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,57 +23,81 @@ 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 { runSettings } from "./settings.mjs";
27
+ import { t } from "./i18n.mjs";
28
28
 
29
- const TOOL_DESC = {
30
- claude: "Anthropic 코딩 에이전트 — 서브에이전트 지원",
31
- codex: "OpenAI 코딩 에이전트",
32
- aider: "터미널 페어 프로그래밍",
33
- goose: "Block의 로컬 우선 에이전트",
34
- openhands: "자율 개발 에이전트",
29
+ // 값은 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점(import 1회)에
30
+ // 번역이 확정돼, 같은 프로세스 안에서 언어를 바꿔도(Settings) 이 설명들만 예전 언어로 남는다.
31
+ // 실제 번역은 소비 지점(buildAgentItems)에서 매번 t()를 호출해 얻는다.
32
+ const AGENT_DESC = {
33
+ claude: "Anthropic's coding agent — supports subagents",
34
+ codex: "OpenAI's coding agent",
35
+ aider: "Terminal pair programming",
36
+ goose: "Block's local-first agent",
37
+ openhands: "Autonomous dev agent",
38
+ opencode: "Multi-provider terminal coding agent",
39
+ omp: "Bun-based coding agent — Claude routing only",
40
+ hermes: "Nous Research's general-purpose agent",
41
+ qwen: "Alibaba's terminal coding agent",
42
+ copilot: "GitHub's terminal coding agent",
35
43
  };
36
44
 
37
45
  const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
38
46
 
39
- // 메뉴 항목 빌드(순수 함수 — 테스트 대상). tool이 있는 항목만 →(모델 설정) 가능.
40
- export function buildItems({ loggedIn, installed, savedModels = {}, expanded = false }) {
47
+ // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 에이전트는 없음 — "agents" 한 항목으로 서브메뉴 진입.
48
+ export function buildItems({ loggedIn, savedModels = {} }) {
41
49
  const items = [];
42
- if (!loggedIn) items.push({ id: "login", title: "로그인", desc: "브라우저로 로그인하고 API 키를 저장합니다" });
50
+ if (!loggedIn) items.push({ id: "login", title: t("Log in"), desc: t("Log in via browser and save the API key") });
43
51
  items.push({
44
52
  id: "chat",
45
53
  tool: "chat",
46
- title: "모델과 채팅",
47
- desc: `대화형 채팅을 시작합니다${savedModels.chat ? ` · 모델: ${savedModels.chat}` : ""}`,
54
+ title: t("Chat with a model"),
55
+ desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
48
56
  });
49
- const tools = expanded ? Object.keys(RECIPES) : ["claude", "codex"];
50
- for (const t of tools) {
51
- const r = RECIPES[t];
57
+ items.push({ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") });
58
+ items.push(
59
+ { id: "models", title: t("Model list"), desc: t("All models — price and context included") },
60
+ { id: "top", title: t("Leaderboard"), desc: t("Model rankings · provider share") },
61
+ { id: "usage", title: t("Usage report"), desc: t("Spend over the last 30 days") },
62
+ { id: "balance", title: t("Balance"), desc: t("Check your credit balance") },
63
+ { id: "health", title: t("Health check"), desc: t("Gateway connectivity · login status") },
64
+ { id: "settings", title: t("Settings"), desc: t("Language, default models, account, updates") },
65
+ );
66
+ return items;
67
+ }
68
+
69
+ // 에이전트 서브메뉴 항목(순수 함수 — 테스트 대상). RECIPES(자동 실행) + MANUAL(설정 안내만) 전부 포함.
70
+ export function buildAgentItems({ installed, savedModels = {} }) {
71
+ const items = [];
72
+ for (const [tool, r] of Object.entries(RECIPES)) {
52
73
  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]}` : ""}`
74
+ id: `launch:${tool}`,
75
+ tool,
76
+ title: `${r.label}${installed[tool] ? "" : autoInstallable(r) ? ` (${t("install")})` : ` (${t("not installed")})`}`,
77
+ desc: installed[tool]
78
+ ? `${t(AGENT_DESC[tool] ?? "")}${savedModels[tool] ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`
58
79
  : autoInstallable(r)
59
- ? `Enter로 바로 설치 — ${r.install}`
60
- : `수동 설치: ${hyperlink(r.install)}`,
80
+ ? `${t("Press Enter to install now")} — ${r.install}`
81
+ : `${t("Manual install:")} ${hyperlink(r.install)}`,
61
82
  });
62
83
  }
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: "다른 에이전트 · 모델 목록 · 잔액 · 리더보드" });
84
+ for (const [tool, m] of Object.entries(MANUAL)) {
85
+ items.push({
86
+ id: `launch:${tool}`,
87
+ tool,
88
+ manual: true,
89
+ title: `${m.label} (${t("setup required")})`,
90
+ desc: installed[tool]
91
+ ? t("Installed — press Enter for the {file} setup snippet", { file: m.file })
92
+ : `${t("✗ not installed —")} ${m.install}`,
93
+ });
73
94
  }
74
95
  return items;
75
96
  }
76
97
 
77
98
  // 항목별 기본 모델 저장(launch_models). 로그인 전에도 동작 — /v1/models는 공개.
78
- function saveModel(tool, id) {
99
+ // settings.mjs의 "Default models" 화면도 재사용(순환 import — 아래 runList 주석 참고).
100
+ export function saveModel(tool, id) {
79
101
  const cfg = readConfig() ?? {};
80
102
  cfg.launch_models = { ...cfg.launch_models, [tool]: id };
81
103
  writeConfig(cfg);
@@ -93,11 +115,11 @@ async function pickModelFor(item) {
93
115
  try {
94
116
  models = await fetchModels();
95
117
  } catch (e) {
96
- console.log(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
118
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
97
119
  return undefined;
98
120
  }
99
121
  const m = await select(models, {
100
- message: `${item.title.replace(/\s+\((설치|미설치)\)/, "")} — 모델 선택`,
122
+ message: `${item.title} — ${t("select a model")}`,
101
123
  formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
102
124
  matches: matchModels,
103
125
  limit: 12,
@@ -108,14 +130,14 @@ async function pickModelFor(item) {
108
130
  // 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
109
131
  async function ensureInstalled(r) {
110
132
  if (!autoInstallable(r)) {
111
- console.log(`${r.label}는 수동 설치가 필요합니다:\n ${style.bold(hyperlink(r.install))}`);
133
+ console.log(`${t("{label} needs manual installation:", { label: r.label })}\n ${style.bold(hyperlink(r.install))}`);
112
134
  return false;
113
135
  }
114
136
  const rl = createInterface({ input: process.stdin, output: process.stdout });
115
- const ans = await new Promise((res) => rl.question(`${r.label}가 설치되어 있지 않습니다. 지금 설치할까요? [Y/n] `, res));
137
+ const ans = await new Promise((res) => rl.question(t("{label} isn't installed. Install it now? [Y/n] ", { label: r.label }), res));
116
138
  rl.close();
117
139
  if (/^n/i.test(ans.trim())) {
118
- console.log(`설치 취소 — 수동 설치: ${r.install}`);
140
+ console.log(t("Install canceled — manual install: {install}", { install: r.install }));
119
141
  return false;
120
142
  }
121
143
  console.log(style.dim(`$ ${r.install}`));
@@ -125,59 +147,26 @@ async function ensureInstalled(r) {
125
147
  c.on("error", () => res(1));
126
148
  });
127
149
  if (code !== 0) {
128
- console.log(`${style.red("✗")} 설치 실패 (exit ${code})`);
150
+ console.log(`${style.red("✗")} ${t("Install failed (exit {code})", { code })}`);
129
151
  return false;
130
152
  }
131
- console.log(`${style.green("✓")} ${r.label} 설치 완료`);
153
+ console.log(`${style.green("✓")} ${t("{label} installed", { label: r.label })}`);
132
154
  return true;
133
155
  }
134
156
 
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() {
157
+ // 화살표 메뉴 루프(키보드 전용) — {item, model?} · "quit"(ctrl+c) · null(esc/q) 반환. 비TTY면 undefined.
158
+ // 메인 메뉴·에이전트 서브메뉴·Settings 서브메뉴(settings.mjs)가 공유하는 렌더 루프 —
159
+ // header/footer/초기 커서만 다르다. settings.mjs가 이 함수를 import하고 menu.mjs가
160
+ // settings.mjs의 runSettings를 import하는 순환 참조가 생기지만, 둘 다 모듈 최상위가 아니라
161
+ // 사용자가 실제로 메뉴를 조작하는 async 함수 안에서만 서로를 참조하므로 안전하다
162
+ // (모듈 최상위에서 즉시 호출하면 "Cannot access before initialization"이 난다 — 이 제약 유지 필수).
163
+ export async function runList(items, { header, footer, initialCursor = 0 } = {}) {
146
164
  const input = process.stdin;
147
165
  const output = process.stdout;
148
166
  if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
149
167
 
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
-
168
+ let cursor = Math.max(0, Math.min(initialCursor, items.length - 1));
178
169
  let lastRows = 0;
179
- let lastLines = [];
180
- let cprRow = 0;
181
170
 
182
171
  const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
183
172
  const erase = () => {
@@ -187,7 +176,7 @@ async function menuSelect() {
187
176
  };
188
177
  const render = () => {
189
178
  const lines = [
190
- `${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
179
+ header,
191
180
  "",
192
181
  ...items.flatMap((it, i) =>
193
182
  i === cursor
@@ -195,38 +184,20 @@ async function menuSelect() {
195
184
  : [` ${it.title}`, ` ${style.dim(it.desc)}`],
196
185
  ),
197
186
  "",
198
- style.dim("↑/↓·휠 이동 • enter·클릭 실행 • →·우클릭 모델 설정 • esc 종료"),
187
+ style.dim(footer),
199
188
  ];
200
189
  erase();
201
190
  output.write(lines.join("\n"));
202
191
  lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
203
- lastLines = lines;
204
- output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
205
192
  };
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;
193
+ const move = (dir) => {
194
+ cursor = Math.max(0, Math.min(items.length - 1, cursor + dir));
221
195
  };
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
196
 
225
197
  return new Promise((resolve) => {
226
198
  const suspend = () => {
227
199
  input.off("data", onData);
228
200
  input.setRawMode(false);
229
- setMouse(output, false);
230
201
  output.write("\x1b[?25h");
231
202
  erase();
232
203
  };
@@ -234,7 +205,6 @@ async function menuSelect() {
234
205
  input.setRawMode(true);
235
206
  input.resume();
236
207
  output.write("\x1b[?25l");
237
- setMouse(output, true);
238
208
  input.on("data", onData);
239
209
  render();
240
210
  };
@@ -243,7 +213,7 @@ async function menuSelect() {
243
213
  input.pause();
244
214
  resolve(value);
245
215
  };
246
- // 모델 셀렉터는 자체 raw-mode·마우스 관리 — 메뉴 입력을 내려놓고 넘긴다.
216
+ // 모델 셀렉터는 자체 raw-mode 관리 — 메뉴 입력을 내려놓고 넘긴다.
247
217
  // 선택되면 ollama처럼 즉시 실행으로 이어가고, 취소면 메뉴 복귀.
248
218
  const configure = async (it) => {
249
219
  suspend();
@@ -258,42 +228,18 @@ async function menuSelect() {
258
228
 
259
229
  const feed = keyFeeder();
260
230
  const onData = async (chunk) => {
261
- let dirty = false; // CPR만 온 턴에 재렌더하면 렌더→CPR 질의→응답 무한 루프
231
+ let dirty = false;
262
232
  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
- }
233
+ if (key.type === "ctrlc") return finish("quit"); // 어느 메뉴에 있든 즉시 전체 종료
234
+ if (key.type === "escape" || (key.type === "char" && key.value === "q")) return finish(null); // 서브메뉴면 상위로, 최상위면 종료
270
235
  if (key.type === "enter" || (key.type === "char" && key.value === " ")) {
271
236
  return finish({ item: items[cursor] });
272
237
  }
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);
238
+ if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
293
239
  else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
294
240
  else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
295
241
  const it = items[cursor];
296
- if (!it.tool) continue;
242
+ if (!it.tool || it.manual) continue; // 도구 없는 항목·수동 설정 항목은 모델 설정 불가
297
243
  return void (await configure(it));
298
244
  } else continue;
299
245
  dirty = true;
@@ -305,19 +251,42 @@ async function menuSelect() {
305
251
  input.setRawMode(true);
306
252
  input.resume();
307
253
  output.write("\x1b[?25l");
308
- setMouse(output, true);
309
254
  input.on("data", onData);
310
255
  render();
311
256
  });
312
257
  }
313
258
 
259
+ async function menuSelect() {
260
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
261
+ const cfg = readConfig();
262
+ const items = buildItems({ loggedIn: Boolean(cfg?.key), savedModels: cfg?.launch_models ?? {} });
263
+ const last = cfg?.last_selection;
264
+ const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
265
+ return runList(items, {
266
+ header: `${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
267
+ footer: t("↑/↓ move • enter run • → set model • esc quit"),
268
+ initialCursor,
269
+ });
270
+ }
271
+
272
+ async function agentSelect() {
273
+ const installed = Object.fromEntries(
274
+ [...Object.entries(RECIPES), ...Object.entries(MANUAL)].map(([tool, r]) => [tool, onPath(r.bin)]),
275
+ );
276
+ const items = buildAgentItems({ installed, savedModels: readConfig()?.launch_models ?? {} });
277
+ return runList(items, {
278
+ header: style.bold(style.plum(t("Run an agent via PleumRouter"))),
279
+ footer: t("↑/↓ move • enter run • → set model • esc back"),
280
+ });
281
+ }
282
+
314
283
  // bare `pleum` 진입점. 비TTY면 false(호출자가 HELP 출력), 그 외 true.
315
- // ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·Esc 종료 제외).
284
+ // ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·ctrl+c 종료 제외).
316
285
  export async function mainMenu() {
317
286
  for (;;) {
318
287
  const picked = await menuSelect();
319
288
  if (picked === undefined) return false; // 비TTY
320
- if (picked === null) return true; // Esc — ollama처럼 조용히 종료
289
+ if (picked === null || picked === "quit") return true; // esc/q/ctrl+c — ollama처럼 조용히 종료
321
290
 
322
291
  const { item, model } = picked;
323
292
  if (item.id !== "login") saveLastSelection(item.id);
@@ -348,10 +317,23 @@ export async function mainMenu() {
348
317
  case "health":
349
318
  await health();
350
319
  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 });
320
+ case "settings": {
321
+ const r = await runSettings();
322
+ if (r === "quit") return true; // ctrl+c 또는 재설치(upgrade) 시작 — 전체 종료
323
+ continue; // esc/q — 메인 메뉴로 복귀
324
+ }
325
+ case "agents": {
326
+ const agentPicked = await agentSelect();
327
+ if (agentPicked === "quit") return true; // 서브메뉴에서 ctrl+c → 전체 종료
328
+ if (!agentPicked) continue; // esc/q — 메인 메뉴로 복귀
329
+ const { item: agentItem, model: agentModel } = agentPicked;
330
+ if (agentModel && !agentItem.manual) saveModel(agentItem.tool, agentModel);
331
+ if (!agentItem.manual) {
332
+ const r = RECIPES[agentItem.tool];
333
+ if (!onPath(r.bin) && !(await ensureInstalled(r))) continue;
334
+ }
335
+ const agentSaved = readConfig()?.launch_models ?? {};
336
+ await launch(agentItem.tool, agentModel ?? agentSaved[agentItem.tool], [], { exit: false });
355
337
  console.log(); // 에이전트 출력과 다음 메뉴 사이 여백
356
338
  continue; // ollama처럼 에이전트 종료 후 메뉴 복귀
357
339
  }
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
  }