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/recipes.mjs CHANGED
@@ -1,8 +1,15 @@
1
1
  import { ROOT, V1 } from "./config.mjs";
2
2
 
3
- // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration.
3
+ // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration + 에이전트별 공식 문서 조사(2026-07).
4
4
  // type "env" → 환경변수만 세팅하고 바로 실행.
5
5
  // type "codex-home" → 격리된 CODEX_HOME에 config.toml을 써서 실행(사용자 ~/.codex 보존).
6
+ // note 필드는 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점에 번역이
7
+ // 고정돼 같은 프로세스 안에서 언어를 바꿔도(Settings) 안내문이 갱신되지 않는다.
8
+ // 실제 번역은 소비 지점(launch.mjs)에서 매번 t()를 호출해 얻는다.
9
+ //
10
+ // 커스텀 엔드포인트를 지원하지 않아 PleumRouter로 라우팅할 수 없는 도구는 등록하지 않는다(조사 결과, 2026-07):
11
+ // Gemini CLI(구글 백엔드 전용, PR#2899 머지 거부) · Cursor CLI(구독 백엔드 전용, 공식 확인) ·
12
+ // Amp(Sourcegraph 백엔드 전용) · OpenClaw(멀티채널 게이트웨이 데몬 — 세션형 코딩 CLI가 아님 + 파일설정 전용).
6
13
  export const RECIPES = {
7
14
  claude: {
8
15
  label: "Claude Code",
@@ -18,6 +25,14 @@ export const RECIPES = {
18
25
  }),
19
26
  args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
20
27
  },
28
+ codex: {
29
+ label: "Codex CLI",
30
+ bin: "codex",
31
+ type: "codex-home",
32
+ install: "npm i -g @openai/codex",
33
+ env: (key) => ({ PLEUM_API_KEY: key }),
34
+ args: () => [],
35
+ },
21
36
  aider: {
22
37
  label: "Aider",
23
38
  bin: "aider",
@@ -31,7 +46,7 @@ export const RECIPES = {
31
46
  bin: "goose",
32
47
  type: "env",
33
48
  install: "https://block.github.io/goose/docs/getting-started/installation",
34
- note: "Goose는 모델을 설정에서 지정합니다 — `goose configure`에서 openai/<id> 형식 사용.",
49
+ note: "Goose sets its model in its own config — use the openai/<id> format in `goose configure`.",
35
50
  env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
36
51
  args: () => [],
37
52
  },
@@ -40,16 +55,73 @@ export const RECIPES = {
40
55
  bin: "openhands",
41
56
  type: "env",
42
57
  install: "python -m pip install openhands-ai",
43
- note: "OpenHands는 모델명 앞에 openai/ 접두사를 붙여 설정하세요.",
58
+ note: "For OpenHands, prefix the model name with openai/ in its config.",
44
59
  env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
45
60
  args: () => [],
46
61
  },
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 }),
62
+ opencode: {
63
+ label: "opencode",
64
+ bin: "opencode",
65
+ type: "env",
66
+ install: "npm i -g opencode-ai@latest",
67
+ // opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
68
+ // 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
69
+ note: "Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched.",
70
+ env: (key, model) => ({
71
+ PLEUM_API_KEY: key,
72
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
73
+ provider: {
74
+ pleum: {
75
+ npm: "@ai-sdk/openai-compatible",
76
+ name: "PleumRouter",
77
+ options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
78
+ models: { [model || "gpt-4.1"]: {} },
79
+ },
80
+ },
81
+ }),
82
+ }),
83
+ args: (m) => ["--model", `pleum/${m || "gpt-4.1"}`],
84
+ },
85
+ omp: {
86
+ label: "omp (Oh My Pi)",
87
+ bin: "omp",
88
+ type: "env",
89
+ install: "curl -fsSL https://omp.sh/install | sh",
90
+ // omp는 커스텀 OpenAI 호환 엔드포인트는 YAML 설정 파일로만 읽지만, 내장 anthropic
91
+ // provider만은 claude와 동일한 ANTHROPIC_BASE_URL env var로 리디렉션된다.
92
+ note: "Needs the Bun runtime (bun >= 1.3.14) on PATH. Only Claude-family models are routed via env vars.",
93
+ env: (key) => ({ ANTHROPIC_BASE_URL: ROOT, ANTHROPIC_API_KEY: key }),
94
+ args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
95
+ },
96
+ hermes: {
97
+ label: "Hermes Agent",
98
+ bin: "hermes",
99
+ type: "env",
100
+ install: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
101
+ note: "Pick the model after it starts with the `hermes model` command.",
102
+ env: (key) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key }),
103
+ args: () => [],
104
+ },
105
+ qwen: {
106
+ label: "Qwen Code",
107
+ bin: "qwen",
108
+ type: "env",
109
+ install: "npm install -g @qwen-code/qwen-code@latest",
110
+ env: (key, model) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key, OPENAI_MODEL: model || "gpt-4.1" }),
111
+ args: () => [],
112
+ },
113
+ copilot: {
114
+ label: "GitHub Copilot CLI",
115
+ bin: "copilot",
116
+ type: "env",
117
+ install: "npm install -g @github/copilot",
118
+ note: "Needs GitHub Copilot CLI 2026-04 or later for BYOK provider routing support.",
119
+ env: (key, model) => ({
120
+ COPILOT_PROVIDER_BASE_URL: V1,
121
+ COPILOT_PROVIDER_TYPE: "openai",
122
+ COPILOT_PROVIDER_API_KEY: key,
123
+ COPILOT_MODEL: model || "gpt-4.1",
124
+ }),
53
125
  args: () => [],
54
126
  },
55
127
  };
@@ -72,22 +144,10 @@ wire_api = "responses"
72
144
 
73
145
  // provider 정의가 파일에 있어야 하는 도구 — 자동 실행 대신 설정 스니펫만 안내.
74
146
  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
147
  crush: {
90
148
  label: "Crush",
149
+ bin: "crush",
150
+ install: "npm install -g @charmland/crush",
91
151
  file: "crush.json",
92
152
  snippet: () => `{
93
153
  "providers": {
package/src/run.mjs CHANGED
@@ -6,6 +6,7 @@ import { fetchModels, formatTable, matchModels } from "./models.mjs";
6
6
  import { style, divider } from "./ansi.mjs";
7
7
  import { createMarkdownStream } from "./markdown.mjs";
8
8
  import { spinner, select } from "./interactive.mjs";
9
+ import { t } from "./i18n.mjs";
9
10
 
10
11
  const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
11
12
 
@@ -67,14 +68,14 @@ export async function run(rest) {
67
68
  if (!model && !oneShot) {
68
69
  if (!process.stdin.isTTY) {
69
70
  // 비대화형(non-TTY) stdin이면 인터랙티브 모델 선택이 멈춰버리므로 명확히 안내하고 종료(L10).
70
- console.error('모델을 지정하세요: pleum run <model> ["프롬프트"] (비대화형 stdin에선 모델 선택 불가)');
71
+ console.error(t('Specify a model: pleum run <model> ["prompt"] (model selection isn\'t available on non-interactive stdin)'));
71
72
  process.exit(1);
72
73
  }
73
74
  model = await pickModel(); // 인터랙티브 모델 선택
74
75
  if (!model) process.exit(1);
75
76
  }
76
77
  if (!model) {
77
- console.error('사용법: pleum run <model> ["프롬프트"]\n모델 목록: pleum models');
78
+ console.error(t('Usage: pleum run <model> ["prompt"]\nModel list: pleum models'));
78
79
  process.exit(1);
79
80
  }
80
81
 
@@ -84,7 +85,7 @@ export async function run(rest) {
84
85
  if (Array.isArray(content)) {
85
86
  const n = content.filter((p) => p.type === "image_url").length;
86
87
  // 프롬프트 내 로컬 이미지 경로를 자동 첨부·업로드하므로 조용히 올리지 않고 명시 고지한다(M8).
87
- console.error(`ℹ 프롬프트의 로컬 이미지 ${n}개를 첨부해 업로드합니다.`);
88
+ console.error(`ℹ ${t("Attaching and uploading {n} local image(s) from the prompt.", { n })}`);
88
89
  }
89
90
  const messages = [{ role: "user", content }];
90
91
  try {
@@ -204,7 +205,7 @@ async function repl(key, initialModel, initialVerbose) {
204
205
  const onClose = () => {
205
206
  if (pickerActive) return;
206
207
  saveHistory(rl.history);
207
- console.log(`\n${style.dim("안녕히 가세요.")}`);
208
+ console.log(`\n${style.dim(t("Goodbye."))}`);
208
209
  process.exit(0);
209
210
  };
210
211
 
@@ -220,7 +221,7 @@ async function repl(key, initialModel, initialVerbose) {
220
221
  return;
221
222
  }
222
223
  lastSigint = now;
223
- console.log(`\n${style.dim("한 번 더 Ctrl+C 하면 종료 (/exit 도 가능)")}`);
224
+ console.log(`\n${style.dim(t("Ctrl+C again to quit (or /exit)"))}`);
224
225
  rl.prompt();
225
226
  };
226
227
 
@@ -240,23 +241,25 @@ async function repl(key, initialModel, initialVerbose) {
240
241
 
241
242
  makeRl(loadHistory());
242
243
  console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
243
- console.log(style.dim('/help 명령 · /exit 종료 · 여러 줄은 """ 감싸기 · 생성 중 Ctrl+C=중단'));
244
+ console.log(style.dim(t('/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation')));
244
245
  console.log(divider());
245
246
  rl.prompt();
246
247
  }
247
248
 
248
- const SLASH_HELP = `명령:
249
- /help, /? 이 도움말
250
- /clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
251
- /system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
252
- /model [id] 세션 유지한 채 모델 전환(인자 없으면 화살표 선택)
253
- /models [검색어] 모델 목록 보기
254
- /save [이름] 현재 세션 저장(기본 last)
255
- /load [이름] 세션 불러오기
256
- /verbose 토큰·속도 통계 표시 토글
257
- /exit, /quit 종료
258
- 여러 줄 입력: """ 한 줄 → 본문 → """ 한 줄
259
- 이미지: 메시지에 이미지 파일 경로를 그대로 포함(예: 이 그림 설명해줘 ./shot.png)`;
249
+ // t() 키(영어 원문)만 담는다 — 모듈 로드 시점에 t()를 호출하면 같은 프로세스 안에서
250
+ // 언어를 바꿔도(Settings) 이 도움말만 예전 언어로 남는다. 실제 번역은 소비 지점(handleSlash)에서.
251
+ const SLASH_HELP = `Commands:
252
+ /help, /? this help
253
+ /clear reset the conversation context (keeps the system prompt)
254
+ /system [text] set the system prompt (no arg shows the current value)
255
+ /model [id] switch models mid-session (no arg = arrow-key picker)
256
+ /models [query] browse the model list
257
+ /save [name] save the current session (default: last)
258
+ /load [name] load a saved session
259
+ /verbose toggle token/speed stats
260
+ /exit, /quit quit
261
+ Multi-line input: a line with """ → body → a line with """
262
+ Images: include an image file path right in the message (e.g. describe this ./shot.png)`;
260
263
 
261
264
  async function handleSlash(text, ctx) {
262
265
  const [cmd] = text.slice(1).split(/\s+/);
@@ -264,41 +267,41 @@ async function handleSlash(text, ctx) {
264
267
  switch (cmd) {
265
268
  case "help":
266
269
  case "?":
267
- console.log(SLASH_HELP);
270
+ console.log(t(SLASH_HELP));
268
271
  break;
269
272
  case "clear":
270
273
  ctx.clear();
271
- console.log(`${style.green("✓")} 컨텍스트 초기화`);
274
+ console.log(`${style.green("✓")} ${t("Context cleared")}`);
272
275
  break;
273
276
  case "system":
274
277
  if (arg) {
275
278
  ctx.system = arg;
276
- console.log(`${style.green("✓")} 시스템 프롬프트 설정`);
279
+ console.log(`${style.green("✓")} ${t("System prompt set")}`);
277
280
  } else {
278
- console.log(ctx.system ? `현재 시스템 프롬프트:\n${ctx.system}` : "시스템 프롬프트 없음");
281
+ console.log(ctx.system ? `${t("Current system prompt:")}\n${ctx.system}` : t("No system prompt"));
279
282
  }
280
283
  break;
281
284
  case "model":
282
285
  if (arg) {
283
286
  ctx.model = arg;
284
- console.log(`${style.green("✓")} 모델 전환 → ${style.plum(arg)}`);
287
+ console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(arg)}`);
285
288
  } else if (ctx.pickModel) {
286
289
  const picked = await ctx.pickModel();
287
290
  if (picked) {
288
291
  ctx.model = picked;
289
- console.log(`${style.green("✓")} 모델 전환 → ${style.plum(picked)}`);
292
+ console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(picked)}`);
290
293
  } else {
291
- console.log(`현재 모델: ${style.plum(ctx.model)}`);
294
+ console.log(`${t("Current model:")} ${style.plum(ctx.model)}`);
292
295
  }
293
296
  } else {
294
- console.log(`현재 모델: ${ctx.model}\n전환: /model <id> · 목록: /models`);
297
+ console.log(`${t("Current model:")} ${ctx.model}\n${t("switch: /model <id> · list: /models")}`);
295
298
  }
296
299
  break;
297
300
  case "models":
298
301
  try {
299
302
  const models = await fetchModels();
300
303
  const rows = matchModels(models, arg);
301
- console.log(rows.length ? formatTable(rows) : "일치하는 모델 없음");
304
+ console.log(rows.length ? formatTable(rows) : t("No matching models"));
302
305
  } catch (e) {
303
306
  console.error(`${style.red("✗")} ${e.message}`);
304
307
  }
@@ -306,25 +309,25 @@ async function handleSlash(text, ctx) {
306
309
  case "save":
307
310
  try {
308
311
  const name = saveSession(arg || "last", ctx.snapshot());
309
- console.log(`${style.green("✓")} 세션 저장: ${name}`);
312
+ console.log(`${style.green("✓")} ${t("Session saved:")} ${name}`);
310
313
  } catch (e) {
311
- console.error(`${style.red("✗")} 저장 실패: ${e.message}`);
314
+ console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: e.message })}`);
312
315
  }
313
316
  break;
314
317
  case "load":
315
318
  try {
316
319
  ctx.restore(loadSession(arg || "last"));
317
- console.log(`${style.green("✓")} 세션 불러옴 (모델: ${ctx.model})`);
320
+ console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: ctx.model })}`);
318
321
  } catch {
319
- console.error(`${style.red("✗")} 세션 없음: ${arg || "last"}`);
322
+ console.error(`${style.red("✗")} ${t("No such session: {name}", { name: arg || "last" })}`);
320
323
  }
321
324
  break;
322
325
  case "verbose":
323
326
  ctx.verbose = !ctx.verbose;
324
- console.log(`${style.green("✓")} 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
327
+ console.log(`${style.green("✓")} ${t("Stats {state}", { state: ctx.verbose ? t("on") : t("off") })}`);
325
328
  break;
326
329
  default:
327
- console.log(`알 수 없는 명령: /${cmd} (/help)`);
330
+ console.log(t("Unknown command: /{cmd} (/help)", { cmd }));
328
331
  }
329
332
  }
330
333
 
@@ -332,7 +335,7 @@ async function handleSlash(text, ctx) {
332
335
  // 비용은 마지막 SSE 청크의 cost.krw — 스트리밍 응답엔 X-Cost-Krw 헤더가 없다(백엔드 계약).
333
336
  export async function streamChat(key, model, messages, { verbose = false, quiet = false, signal, fetchImpl = fetch } = {}) {
334
337
  const t0 = Date.now();
335
- const sp = spinner(`${model} 생각 중…`);
338
+ const sp = spinner(t("{model} thinking…", { model }));
336
339
  let res;
337
340
  try {
338
341
  res = await fetchImpl(`${V1}/chat/completions`, {
@@ -344,7 +347,7 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
344
347
  } catch (e) {
345
348
  sp.stop();
346
349
  if (isAbort(e)) {
347
- console.error(style.dim("⏹ 중단됨"));
350
+ console.error(style.dim(t("⏹ aborted")));
348
351
  return "";
349
352
  }
350
353
  throw e;
@@ -400,7 +403,7 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
400
403
  }
401
404
  sp.stop();
402
405
  md.finish();
403
- if (aborted) console.error(style.dim("⏹ 생성 중단됨 — 부분 응답은 컨텍스트에 유지"));
406
+ if (aborted) console.error(style.dim(t("⏹ generation aborted — the partial reply stays in context")));
404
407
 
405
408
  const ms = Date.now() - t0;
406
409
  const genSec = (Date.now() - (firstAt || t0)) / 1000;
@@ -421,13 +424,13 @@ async function pickModel() {
421
424
  try {
422
425
  models = await fetchModels();
423
426
  } catch (e) {
424
- console.error(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
427
+ console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
425
428
  return null;
426
429
  }
427
430
  if (!models.length) return null;
428
431
  if (process.stdin.isTTY && process.stdout.isTTY) {
429
432
  return select(models, {
430
- message: "모델 선택",
433
+ message: t("Select a model"),
431
434
  formatItem: (m) => `${m.id}${m.display_name ? ` ${style.dim(m.display_name)}` : ""}`,
432
435
  matches: matchModels,
433
436
  limit: 12,
@@ -447,14 +450,14 @@ async function pickModelNumbered(models) {
447
450
  shown.forEach((m, i) =>
448
451
  console.log(` ${String(i + 1).padStart(2)}. ${m.id}${m.display_name ? ` (${m.display_name})` : ""}`),
449
452
  );
450
- if (pool.length > shown.length) console.log(` … ${pool.length - shown.length}개 더 — 검색어로 좁히기`);
451
- const ans = (await ask("\n번호 선택 또는 검색어 (Enter=취소): ")).trim();
453
+ if (pool.length > shown.length) console.log(` ${t("… {n} more — narrow with a search term", { n: pool.length - shown.length })}`);
454
+ const ans = (await ask(`\n${t("Choose a number, or search (Enter to cancel): ")}`)).trim();
452
455
  if (!ans) return null;
453
456
  const n = Number(ans);
454
457
  if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1].id;
455
458
  const next = matchModels(models, ans);
456
459
  pool = next.length ? next : models;
457
- if (!next.length) console.log("일치 없음 — 전체 목록으로.");
460
+ if (!next.length) console.log(t("No matches — showing the full list."));
458
461
  }
459
462
  } finally {
460
463
  rl.close();
@@ -0,0 +1,168 @@
1
+ // 런처 Settings 서브메뉴 — agentSelect()(menu.mjs)와 동일한 "한 항목 → 서브메뉴" 패턴.
2
+ // menu.mjs의 runList/saveModel을 가져오고 menu.mjs는 이 모듈의 runSettings를 가져오는
3
+ // 순환 import가 있다 — 둘 다 함수 선언(export function)이라 ESM 인스턴스화 단계에서
4
+ // 먼저 호이스팅되므로 안전하다. 다만 앞으로 이 파일을 고칠 때도 두 모듈 모두 상대방의
5
+ // export를 "async 함수 바디 안에서만" 참조하는 규칙은 유지할 것(모듈 최상위에서 값으로
6
+ // 즉시 참조하면 순환의 평가 순서에 따라 깨질 수 있다).
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { style } from "./ansi.mjs";
10
+ import { select, confirm } from "./interactive.mjs";
11
+ import { readConfig, writeConfig, clearCredentials } from "./config.mjs";
12
+ import { getLocale, LANGS, t } from "./i18n.mjs";
13
+ import { RECIPES } from "./recipes.mjs";
14
+ import { fetchModels, matchModels } from "./models.mjs";
15
+ import { login } from "./login.mjs";
16
+ import { upgrade } from "./upgrade.mjs";
17
+ import { completion } from "./completion.mjs";
18
+ import { lang } from "./lang.mjs";
19
+ import { runList, saveModel } from "./menu.mjs";
20
+
21
+ // Settings 서브메뉴 항목(순수 함수 — 테스트 대상).
22
+ export function buildSettingsItems({ lang, loggedIn, keyPrefix, version }) {
23
+ return [
24
+ { id: "settings:lang", title: t("Language"), desc: `${t("current")}: ${LANGS[lang] ?? lang}` },
25
+ { id: "settings:models", title: t("Default models"), desc: t("Set the default model used per agent") },
26
+ {
27
+ id: "settings:account",
28
+ title: t("Account"),
29
+ desc: loggedIn ? `${t("Logged in")} (${t("key")} ${keyPrefix}…)` : t("Not logged in"),
30
+ },
31
+ { id: "settings:update", title: t("Update CLI"), desc: t("Current version: {version}", { version }) },
32
+ { id: "settings:completion", title: t("Shell completion"), desc: t("Print a zsh/bash completion script") },
33
+ ];
34
+ }
35
+
36
+ // "Default models" 서브메뉴 항목(순수 함수 — 테스트 대상). chat + RECIPES 전부.
37
+ // 필드명은 의도적으로 `tool`이 아니라 `toolId` — runList()의 → 키바인딩은 `it.tool`이
38
+ // 있으면 자동으로 menu.mjs의 pickModelFor(클리어 옵션 없음)를 띄운다. 이 화면은 Enter로만
39
+ // 진입해 "Use default (clear)"가 있는 자체 피커(pickDefaultModelFor)를 쓰므로 `tool`을
40
+ // 피해 → 를 비활성 상태로 둔다(그래야 Enter 흐름과 중복 실행되지 않는다).
41
+ export function buildDefaultModelItems({ savedModels = {} }) {
42
+ const tools = ["chat", ...Object.keys(RECIPES)];
43
+ return tools.map((tool) => ({
44
+ id: `defaultmodel:${tool}`,
45
+ toolId: tool,
46
+ title: tool === "chat" ? t("Chat with a model") : RECIPES[tool].label,
47
+ desc: savedModels[tool] ? `${t("model")}: ${savedModels[tool]}` : t("not set"),
48
+ }));
49
+ }
50
+
51
+ function readVersion() {
52
+ // bin/pleum.mjs의 version()과 동일한 1줄 패턴 복제 — bin/pleum.mjs는 entry-guard 없이
53
+ // process.argv 스위치를 최상위에서 바로 실행하는 진입점이라 import하면 CLI 디스패치가
54
+ // 함께 실행돼 버린다. menuSelect()도 같은 이유로 이 패턴을 복제해 쓰고 있다.
55
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
56
+ }
57
+
58
+ function clearSavedModel(tool) {
59
+ const cfg = readConfig() ?? {};
60
+ if (cfg.launch_models) delete cfg.launch_models[tool];
61
+ writeConfig(cfg);
62
+ }
63
+
64
+ async function settingsSelect() {
65
+ const cfg = readConfig();
66
+ const items = buildSettingsItems({
67
+ lang: getLocale(),
68
+ loggedIn: Boolean(cfg?.key),
69
+ keyPrefix: cfg?.key ? cfg.key.slice(0, 8) : null,
70
+ version: readVersion(),
71
+ });
72
+ return runList(items, {
73
+ header: style.bold(style.plum(t("Settings"))),
74
+ footer: t("↑/↓ move • enter select • esc back"),
75
+ });
76
+ }
77
+
78
+ const CLEAR_OPTION = { id: "__clear__", display_name: null, isClear: true };
79
+
80
+ async function pickDefaultModelFor(item) {
81
+ let models;
82
+ try {
83
+ models = await fetchModels();
84
+ } catch (e) {
85
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
86
+ return undefined;
87
+ }
88
+ const picked = await select([CLEAR_OPTION, ...models], {
89
+ message: `${item.title} — ${t("select a model")}`,
90
+ formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) : `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`),
91
+ matches: matchModels, // 쿼리 입력 시 CLEAR_OPTION은 자연히 필터링됨(id/display_name 불일치) — 별도 처리 불요
92
+ limit: 12,
93
+ });
94
+ return picked ?? undefined; // select()는 취소 시 null — pickModelFor 관례처럼 undefined로 정규화
95
+ }
96
+
97
+ async function runDefaultModels() {
98
+ for (;;) {
99
+ const items = buildDefaultModelItems({ savedModels: readConfig()?.launch_models ?? {} });
100
+ const picked = await runList(items, {
101
+ header: style.bold(style.plum(t("Default models"))),
102
+ footer: t("↑/↓ move • enter set model • esc back"),
103
+ });
104
+ if (picked === undefined) return undefined;
105
+ if (picked === null) return null;
106
+ if (picked === "quit") return "quit";
107
+ const result = await pickDefaultModelFor(picked.item);
108
+ if (!result) continue; // 취소/조회 실패 — 목록 다시 표시
109
+ if (result.isClear) clearSavedModel(picked.item.toolId);
110
+ else saveModel(picked.item.toolId, result.id);
111
+ // 메인 런처의 →(설정 즉시 실행)와 의도적으로 다르다 — 저장만 하고 목록으로 복귀.
112
+ }
113
+ }
114
+
115
+ async function runAccount() {
116
+ const cfg = readConfig();
117
+ if (cfg?.key) {
118
+ const ok = await confirm(t("Log out? [Y/n] "), { defaultYes: true });
119
+ if (ok) {
120
+ writeConfig(clearCredentials(cfg));
121
+ console.log(`✓ ${t("Logged out")}`);
122
+ }
123
+ } else {
124
+ await login();
125
+ }
126
+ }
127
+
128
+ async function runCompletion() {
129
+ const picked = await select(
130
+ [{ id: "zsh", label: "zsh" }, { id: "bash", label: "bash" }],
131
+ { message: t("Choose a shell"), formatItem: (x) => x.label },
132
+ );
133
+ if (picked) completion(picked.id); // 출력만 — rc 파일 자동 수정 없음(기존 completion.mjs 그대로)
134
+ }
135
+
136
+ // Settings 서브메뉴 루프. 반환값은 runList 규약을 그대로 전파: "quit"(ctrl+c 또는 재설치
137
+ // 시작) · null(esc/q, 메인 메뉴로) · undefined(비TTY).
138
+ export async function runSettings() {
139
+ for (;;) {
140
+ const picked = await settingsSelect();
141
+ if (picked === undefined) return undefined;
142
+ if (picked === null) return null;
143
+ if (picked === "quit") return "quit";
144
+ switch (picked.item.id) {
145
+ case "settings:lang":
146
+ await lang([]); // TTY 화살표 피커 재사용 — setLocale()이 i18n 캐시를 즉시 갱신하므로
147
+ continue; // 다음 루프의 buildSettingsItems() 재빌드로 새 언어가 자연히 반영된다
148
+ case "settings:models": {
149
+ const r = await runDefaultModels();
150
+ if (r === "quit") return "quit";
151
+ continue;
152
+ }
153
+ case "settings:account":
154
+ await runAccount();
155
+ continue;
156
+ case "settings:update":
157
+ // upgrade()는 자식(npm install) 종료 시 process.exit()을 호출한다. 여기서 continue해
158
+ // 메뉴를 다시 그리면 raw-mode 화면과 npm 출력이 뒤섞이다 프로세스가 갑자기 죽으므로,
159
+ // mainMenu()의 chat 케이스처럼 즉시 루프를 벗어난다.
160
+ console.log(t("Reinstalling — pleum will exit when finished. Run `pleum` again afterward."));
161
+ upgrade();
162
+ return "quit";
163
+ case "settings:completion":
164
+ await runCompletion();
165
+ continue;
166
+ }
167
+ }
168
+ }
package/src/top.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { V1 } from "./config.mjs";
5
5
  import { style, padDisplay, barChart, divider } from "./ansi.mjs";
6
+ import { t } from "./i18n.mjs";
6
7
 
7
8
  export function parseTopArgs(rest = []) {
8
9
  let period = "weekly";
@@ -11,7 +12,7 @@ export function parseTopArgs(rest = []) {
11
12
  else if (["daily", "weekly", "monthly"].includes(rest[i])) period = rest[i];
12
13
  }
13
14
  if (!["daily", "weekly", "monthly"].includes(period)) {
14
- throw new Error(`period는 daily|weekly|monthly 중 하나: ${period}`);
15
+ throw new Error(t("period must be one of daily|weekly|monthly: {period}", { period }));
15
16
  }
16
17
  return { period };
17
18
  }
@@ -25,14 +26,14 @@ const fmtTok = (n) => {
25
26
 
26
27
  // rankings 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
27
28
  export function renderTop(data, styler = style) {
28
- const periodKo = { daily: "일간", weekly: "주간", monthly: "월간" }[data.period] || data.period;
29
+ const periodLabel = { daily: t("daily"), weekly: t("weekly"), monthly: t("monthly") }[data.period] || data.period;
29
30
  const lines = [];
30
- lines.push(`${styler.bold(styler.plum("모델 리더보드"))} ${styler.dim(`· ${periodKo} · 토큰 ${fmtTok(data.total_tokens ?? 0)}`)}`);
31
+ lines.push(`${styler.bold(styler.plum(t("Model leaderboard")))} ${styler.dim(`· ${periodLabel} · ${t("tokens")} ${fmtTok(data.total_tokens ?? 0)}`)}`);
31
32
  lines.push(divider(undefined, styler));
32
33
 
33
34
  const board = data.leaderboard || [];
34
35
  if (!board.length) {
35
- lines.push(styler.dim("데이터 없음"));
36
+ lines.push(styler.dim(t("No data")));
36
37
  } else {
37
38
  const idW = Math.max(...board.map((r) => r.model.length), 5);
38
39
  for (const r of board) {
@@ -53,7 +54,7 @@ export function renderTop(data, styler = style) {
53
54
  const share = data.market_share || [];
54
55
  if (share.length) {
55
56
  lines.push("");
56
- lines.push(styler.bold("제공자 점유율"));
57
+ lines.push(styler.bold(t("Provider share")));
57
58
  const nameW = Math.max(...share.map((s) => s.provider.length), 8);
58
59
  for (const s of share) {
59
60
  lines.push(
@@ -67,6 +68,6 @@ export function renderTop(data, styler = style) {
67
68
  export async function top(rest) {
68
69
  const { period } = parseTopArgs(rest);
69
70
  const res = await fetch(`${V1}/stats/rankings?period=${period}`);
70
- if (!res.ok) throw new Error(`리더보드 조회 실패 (${res.status})`);
71
+ if (!res.ok) throw new Error(t("Failed to fetch leaderboard ({status})", { status: res.status }));
71
72
  console.log(renderTop(await res.json()));
72
73
  }