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/README.md CHANGED
@@ -29,6 +29,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
29
29
 
30
30
  | 명령 | 설명 |
31
31
  |------|------|
32
+ | `pleum` | (인자 없음, TTY) 인터랙티브 런처 메뉴 — 키보드 전용. ↑/↓ 이동 · enter 실행 · → 모델 설정 · esc 종료(서브메뉴에선 뒤로) · ctrl+c 즉시 종료. "에이전트 실행" 진입 시 Claude Code·Codex·opencode·omp·Hermes Agent·Qwen Code·GitHub Copilot CLI 등 지원 에이전트 전체를 설치 여부와 함께 볼 수 있고, 미설치는 확인 후 자동 설치(또는 설정 파일 안내). 실행 종료 후 메뉴 복귀. 파이프에선 도움말 출력 |
32
33
  | `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
33
34
  | `pleum logout` | 저장된 키 삭제 |
34
35
  | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
@@ -39,6 +40,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
39
40
  | `pleum balance` | 크레딧 잔액 조회 |
40
41
  | `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — 최신 로그인 키 필요(`usage:read`) |
41
42
  | `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
43
+ | `pleum lang [code]` | 표시 언어 조회/변경 — `en`(기본)·`ko`·`ja`·`zh`·`es`. 인자 없이 TTY면 화살표로 선택, `PLEUM_LANG` 환경변수로도 덮어쓰기 가능 |
42
44
  | `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
43
45
  | `pleum upgrade` | 최신 버전으로 재설치 |
44
46
  | `pleum version` | 버전 출력 |
package/bin/pleum.mjs CHANGED
@@ -11,38 +11,51 @@ import { health } from "../src/health.mjs";
11
11
  import { completion } from "../src/completion.mjs";
12
12
  import { upgrade } from "../src/upgrade.mjs";
13
13
  import { clearConfig } from "../src/config.mjs";
14
+ import { mainMenu } from "../src/menu.mjs";
15
+ import { lang } from "../src/lang.mjs";
16
+ import { t } from "../src/i18n.mjs";
14
17
 
15
- const HELP = `pleum — PleumRouter 터미널 CLI
18
+ // 명령 구문(왼쪽)은 로케일과 무관 — 설명(오른쪽)만 번역, 폭은 가장 긴 구문 기준으로 계산.
19
+ function buildHelp() {
20
+ const rows = [
21
+ ["pleum", t("(no args) interactive launcher menu — keyboard only (TTY only)")],
22
+ ["pleum login [--device]", t("Log in via browser + auto-save the key")],
23
+ ["", t("--device: browser-less environments (SSH/CI)")],
24
+ ["pleum logout", t("Delete the saved key")],
25
+ ["pleum launch [tool] [--model id]", t("Run a coding agent against PleumRouter")],
26
+ ["", "(claude · aider · codex · goose · openhands)"],
27
+ ["", t("omit the tool to see the supported list and install status")],
28
+ ['pleum run [model] ["prompt"]', t("Terminal chat (REPL) / one-shot / pipe")],
29
+ ["", t("omit the model for an arrow-key picker; /help lists commands")],
30
+ ["pleum models [query] [--json]", t("Model list (price, context included)")],
31
+ ["pleum models <id> --info", t("Model detail (price, perf, per-provider uptime)")],
32
+ ["pleum top [--period weekly]", t("Model leaderboard · provider share (daily|weekly|monthly)")],
33
+ ["pleum balance", t("Check your credit balance")],
34
+ ["pleum usage [--by model] [--days 30]", t("Your spend report (model|provider|api_key|day)")],
35
+ ["pleum health", t("Check gateway connectivity and login status")],
36
+ ["pleum lang [code]", t("Show/change display language (en|ko|ja|zh|es)")],
37
+ ["pleum completion <zsh|bash>", t("Print a shell completion script")],
38
+ ["pleum upgrade", t("Reinstall the latest version")],
39
+ ["pleum version", t("Print the version")],
40
+ ];
41
+ const width = Math.max(...rows.map(([cmd]) => cmd.length)) + 2;
42
+ const body = rows.map(([cmd, desc]) => ` ${cmd.padEnd(width)}${desc}`).join("\n");
16
43
 
17
- pleum login [--device] 브라우저로 로그인 + 키 자동 저장
18
- --device: 브라우저 없는 환경(SSH·CI)
19
- pleum logout 저장된 키 삭제
20
- pleum launch [tool] [--model id] 코딩 에이전트를 PleumRouter로 실행
21
- (claude · aider · codex · goose · openhands)
22
- 도구 생략 시 지원 목록·설치 여부 표시
23
- pleum run [model] ["프롬프트"] 터미널 채팅(REPL) / 1회 실행 / 파이프
24
- 모델 생략 시 화살표로 선택, /help 로 명령
25
- pleum models [query] [--json] 모델 목록(가격·컨텍스트 포함)
26
- pleum models <id> --info 모델 상세(가격·성능·provider별 uptime)
27
- pleum top [--period weekly] 모델 리더보드·제공자 점유율(daily|weekly|monthly)
28
- pleum balance 크레딧 잔액 조회
29
- pleum usage [--by model] [--days 30] 내 지출 리포트(model|provider|api_key|day)
30
- pleum health 게이트웨이 연결·로그인 상태 점검
31
- pleum completion <zsh|bash> 셸 자동완성 스크립트 출력
32
- pleum upgrade 최신 버전으로 재설치
33
- pleum version 버전 출력
44
+ const examples = [
45
+ ["pleum launch claude --model claude-sonnet-4", null],
46
+ ["pleum launch codex --model gpt-4.1 -- --full-auto", null],
47
+ ["pleum run gpt-4.1", t("interactive")],
48
+ [`pleum run gpt-4.1 "${t("Summarize in one line: ...")}"`, t("one-shot")],
49
+ [`cat error.log | pleum run gpt-4.1 "${t("what caused this?")}"`, t("pipe")],
50
+ ["pleum models deepseek/deepseek-chat --info", t("model detail")],
51
+ ["pleum usage --by day --days 7", t("last 7 days, by day")],
52
+ ["pleum login --device", t("SSH/CI environment")],
53
+ ];
54
+ const exWidth = Math.max(...examples.map(([cmd]) => cmd.length)) + 2;
55
+ const exBody = examples.map(([cmd, note]) => ` ${note ? cmd.padEnd(exWidth) + `# ${note}` : cmd}`).join("\n");
34
56
 
35
- 예:
36
- pleum launch claude --model claude-sonnet-4
37
- pleum launch codex --model gpt-4.1 -- --full-auto
38
- pleum run gpt-4.1 # 대화형
39
- pleum run gpt-4.1 "한 줄로 요약: ..." # 1회 실행
40
- cat error.log | pleum run gpt-4.1 "원인은?" # 파이프
41
- pleum models deepseek/deepseek-chat --info # 모델 상세
42
- pleum usage --by day --days 7 # 최근 1주 일별 지출
43
- pleum login --device # SSH/CI 환경
44
-
45
- base_url 덮어쓰기: PLEUM_BASE_URL 환경변수`;
57
+ return `pleum — ${t("PleumRouter terminal CLI")}\n\n${body}\n\n${t("Examples:")}\n${exBody}\n\n${t("Override base_url: PLEUM_BASE_URL environment variable")}`;
58
+ }
46
59
 
47
60
  // `--model id`/`-m id`를 뽑고, 나머지는 도구로 그대로 넘긴다. `--` 이후는 전부 패스스루.
48
61
  function parseLaunchArgs(args) {
@@ -72,7 +85,10 @@ try {
72
85
  break;
73
86
  case "logout":
74
87
  clearConfig();
75
- console.log("✓ 로그아웃됨");
88
+ console.log(`✓ ${t("Logged out")}`);
89
+ break;
90
+ case "lang":
91
+ await lang(rest);
76
92
  break;
77
93
  case "launch": {
78
94
  const tool = rest[0];
@@ -114,13 +130,16 @@ try {
114
130
  version();
115
131
  break;
116
132
  case undefined:
133
+ // TTY면 ollama풍 런처 메뉴, 파이프/스크립트면 기존 도움말.
134
+ if (!(await mainMenu())) console.log(buildHelp());
135
+ break;
117
136
  case "-h":
118
137
  case "--help":
119
138
  case "help":
120
- console.log(HELP);
139
+ console.log(buildHelp());
121
140
  break;
122
141
  default:
123
- console.error(`알 수 없는 명령: ${cmd}\n\n${HELP}`);
142
+ console.error(`${t("Unknown command: {cmd}", { cmd })}\n\n${buildHelp()}`);
124
143
  process.exit(1);
125
144
  }
126
145
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ansi.mjs CHANGED
@@ -49,8 +49,15 @@ export function createStyler(level = detectColorLevel()) {
49
49
  export const style = createStyler();
50
50
 
51
51
  export function stripAnsi(s) {
52
- // eslint 불필요 — CSI 시퀀스만 제거하면 충분(우리가 쓰는 코드 전부 CSI).
53
- return String(s).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
52
+ // CSI(색·커서·마우스) + OSC(하이퍼링크) — displayWidth가 폭을 잘못 세지 않게 둘 다 제거.
53
+ return String(s)
54
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
55
+ .replace(/\x1b\[[0-9;<]*[A-Za-z]/g, "");
56
+ }
57
+
58
+ // OSC 8 클릭 가능한 하이퍼링크. 무색(파이프·NO_COLOR·dumb)이면 텍스트만.
59
+ export function hyperlink(url, text = url, level = style.level) {
60
+ return level ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text;
54
61
  }
55
62
 
56
63
  // 터미널 표시 폭 — CJK(한글 등) 전각 문자는 2칸. 정렬·줄수 계산용.
package/src/balance.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { V1, requireKey } from "./config.mjs";
2
2
  import { style } from "./ansi.mjs";
3
+ import { t } from "./i18n.mjs";
3
4
 
4
5
  export async function balance() {
5
6
  const key = requireKey();
@@ -8,18 +9,18 @@ export async function balance() {
8
9
  });
9
10
  if (!res.ok) {
10
11
  const body = await res.text().catch(() => "");
11
- console.error(`잔액 조회 실패 (HTTP ${res.status})${body ? `: ${body.slice(0, 200)}` : ""}`);
12
+ console.error(`${t("Failed to check balance (HTTP {status})", { status: res.status })}${body ? `: ${body.slice(0, 200)}` : ""}`);
12
13
  if (res.status === 401) {
13
- console.error(style.dim("서버가 아직 API 키 잔액 조회를 지원하지 않거나 키가 만료됐을 수 있습니다 — pleum login 재시도."));
14
+ console.error(style.dim(t("The server may not support API-key balance checks yet, or the key may have expired — try pleum login again.")));
14
15
  }
15
16
  process.exit(1);
16
17
  }
17
18
  const data = await res.json();
18
19
  const plm = data.balance_krw;
19
20
  const usd = data.balance_usd != null ? style.dim(` / $${data.balance_usd.toFixed(4)}`) : "";
20
- console.log(`잔액: ${style.bold(style.plum(`${plm.toLocaleString("ko-KR")} PLM`))}${usd}`);
21
+ console.log(`${t("Balance:")} ${style.bold(style.plum(`${plm.toLocaleString("ko-KR")} PLM`))}${usd}`);
21
22
  const threshold = data.auto_charge_threshold_krw;
22
23
  if (threshold != null && plm < threshold) {
23
- console.log(style.yellow(`⚠ 잔액이 자동충전 임계값(${threshold.toLocaleString("ko-KR")} PLM) 아래입니다.`));
24
+ console.log(style.yellow(`⚠ ${t("Balance is below the auto-charge threshold ({threshold} PLM).", { threshold: threshold.toLocaleString("ko-KR") })}`));
24
25
  }
25
26
  }
@@ -1,10 +1,12 @@
1
1
  // Shell 자동완성 스크립트 출력. 명령·도구는 정적, 모델명은 `pleum models`에서 동적으로 채운다.
2
2
  // 설치: pleum completion zsh >> ~/.zshrc · pleum completion bash >> ~/.bashrc
3
- const COMMANDS = "login logout launch run models top balance usage health version upgrade completion help";
3
+ import { t } from "./i18n.mjs";
4
+
5
+ const COMMANDS = "login logout lang launch run models top balance usage health version upgrade completion help";
4
6
  const TOOLS = "claude aider codex goose openhands opencode crush";
5
7
 
6
8
  function bash() {
7
- return `# pleum bash completion — eval "$(pleum completion bash)" 또는 ~/.bashrc에 추가
9
+ return `# pleum bash completion — eval "$(pleum completion bash)" (${t("or add to ~/.bashrc")})
8
10
  _pleum() {
9
11
  local cur prev; cur="\${COMP_WORDS[COMP_CWORD]}"; prev="\${COMP_WORDS[COMP_CWORD-1]}"
10
12
  if [ "$COMP_CWORD" -eq 1 ]; then
@@ -21,7 +23,7 @@ complete -F _pleum pleum`;
21
23
 
22
24
  function zsh() {
23
25
  return `#compdef pleum
24
- # pleum zsh completion — eval "$(pleum completion zsh)" 또는 fpath에 _pleum로 추가
26
+ # pleum zsh completion — eval "$(pleum completion zsh)" (${t("or add _pleum to fpath")})
25
27
  _pleum() {
26
28
  if (( CURRENT == 2 )); then
27
29
  compadd ${COMMANDS}; return
@@ -39,9 +41,9 @@ export function completion(shell) {
39
41
  if (shell === "bash") return void console.log(bash());
40
42
  if (shell === "zsh") return void console.log(zsh());
41
43
  console.error(
42
- "사용법: pleum completion <zsh|bash>\n" +
43
- ' zsh: eval "$(pleum completion zsh)" (또는 ~/.zshrc에 추가)\n' +
44
- ' bash: eval "$(pleum completion bash)" (또는 ~/.bashrc에 추가)',
44
+ t("Usage: pleum completion <zsh|bash>") + "\n" +
45
+ ` zsh: eval "$(pleum completion zsh)" (${t("or add to ~/.zshrc")})\n` +
46
+ ` bash: eval "$(pleum completion bash)" (${t("or add to ~/.bashrc")})`,
45
47
  );
46
48
  process.exit(1);
47
49
  }
package/src/config.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
4
+ import { t } from "./i18n.mjs";
4
5
 
5
6
  // 공개 게이트웨이 루트. 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
6
7
  export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://router.pleum.ai")
@@ -40,7 +41,7 @@ export function clearConfig() {
40
41
  export function requireKey() {
41
42
  const cfg = readConfig();
42
43
  if (!cfg?.key) {
43
- console.error("로그인이 필요합니다. 먼저 실행하세요:\n pleum login");
44
+ console.error(t("Login required. Run this first:\n pleum login"));
44
45
  process.exit(1);
45
46
  }
46
47
  return cfg.key;
package/src/health.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { ROOT, V1, readConfig } from "./config.mjs";
5
5
  import { style, divider } from "./ansi.mjs";
6
+ import { t } from "./i18n.mjs";
6
7
 
7
8
  const ok = (s) => `${style.green("●")} ${s}`;
8
9
  const bad = (s) => `${style.red("●")} ${s}`;
@@ -14,24 +15,24 @@ export function renderHealth({ root, health, error, ms, keyPrefix }, styler = st
14
15
  lines.push(`${styler.bold(styler.plum("pleum health"))} ${styler.dim(`· ${root}`)}`);
15
16
  lines.push(divider(undefined, styler));
16
17
  if (error) {
17
- lines.push(bad(`게이트웨이 연결 실패: ${error}`));
18
+ lines.push(bad(t("Gateway connection failed: {error}", { error })));
18
19
  } else {
19
20
  const isOk = (v) => v === "ok" || v === true; // 백엔드는 "ok" 문자열
20
- lines.push((isOk(health.status) ? ok : warn)(`게이트웨이 ${health.status}${ms != null ? styler.dim(` (${ms}ms)`) : ""}`));
21
- if (health.db != null) lines.push((isOk(health.db) ? ok : bad)(`DB ${isOk(health.db) ? "정상" : health.db}`));
22
- if (health.redis != null) lines.push((isOk(health.redis) ? ok : bad)(`Redis ${isOk(health.redis) ? "정상" : health.redis}`));
23
- if (health.providers_configured != null) lines.push(ok(`providers ${health.providers_configured}개 구성됨`));
21
+ lines.push((isOk(health.status) ? ok : warn)(`${t("Gateway")} ${health.status}${ms != null ? styler.dim(` (${ms}ms)`) : ""}`));
22
+ if (health.db != null) lines.push((isOk(health.db) ? ok : bad)(`DB ${isOk(health.db) ? t("healthy") : health.db}`));
23
+ if (health.redis != null) lines.push((isOk(health.redis) ? ok : bad)(`Redis ${isOk(health.redis) ? t("healthy") : health.redis}`));
24
+ if (health.providers_configured != null) lines.push(ok(t("{n} providers configured", { n: health.providers_configured })));
24
25
  const fx = health.fx_rate;
25
26
  if (fx != null) {
26
27
  const rate = typeof fx === "object" ? fx.rate : fx; // {rate, applied_rate, source}
27
28
  const src = typeof fx === "object" && fx.source ? styler.dim(` (${fx.source})`) : "";
28
- if (rate != null) lines.push(ok(`환율 USD/KRW ${rate}${src}`));
29
+ if (rate != null) lines.push(ok(t("FX rate USD/KRW {rate}{src}", { rate, src })));
29
30
  }
30
31
  }
31
32
  lines.push(
32
33
  keyPrefix
33
- ? ok(`로그인됨 ${styler.dim(`(키 ${keyPrefix}…)`)}`)
34
- : warn(`로그인 안 됨 ${styler.dim("— pleum login")}`),
34
+ ? ok(`${t("Logged in")} ${styler.dim(`(${t("key")} ${keyPrefix}…)`)}`)
35
+ : warn(`${t("Not logged in")} ${styler.dim("— pleum login")}`),
35
36
  );
36
37
  return lines.join("\n");
37
38
  }
package/src/i18n.mjs ADDED
@@ -0,0 +1,37 @@
1
+ // gettext 스타일 i18n — 영어 원문 자체가 키. 사전에 없으면 원문(영어) 폴백.
2
+ // 기본 로케일은 영어; PLEUM_LANG 환경변수 또는 `pleum lang <code>`로 저장된
3
+ // ~/.pleum/config.json의 lang이 있으면 그것을 쓴다(env가 우선).
4
+
5
+ import { readConfig, writeConfig } from "./config.mjs";
6
+ import { KO } from "./locales/ko.mjs";
7
+ import { JA } from "./locales/ja.mjs";
8
+ import { ZH } from "./locales/zh.mjs";
9
+ import { ES } from "./locales/es.mjs";
10
+
11
+ export const LANGS = { en: "English", ko: "한국어", ja: "日本語", zh: "中文", es: "Español" };
12
+ const DICTS = { ko: KO, ja: JA, zh: ZH, es: ES };
13
+
14
+ let cached; // 프로세스 수명 동안 1회 확정 — CLI는 매 실행이 새 프로세스라 재조회 불필요
15
+ export function getLocale() {
16
+ if (cached) return cached;
17
+ const env = process.env.PLEUM_LANG;
18
+ if (env && LANGS[env]) return (cached = env);
19
+ const cfg = readConfig();
20
+ if (cfg?.lang && LANGS[cfg.lang]) return (cached = cfg.lang);
21
+ return (cached = "en");
22
+ }
23
+
24
+ export function setLocale(code) {
25
+ if (!LANGS[code]) throw new Error(`Unsupported language: ${code} (${Object.keys(LANGS).join(", ")})`);
26
+ cached = code;
27
+ const cfg = readConfig() ?? {};
28
+ cfg.lang = code;
29
+ writeConfig(cfg);
30
+ }
31
+
32
+ // vars의 {key} 플레이스홀더를 치환. 번역 누락 키는 영어 원문을 그대로 보여준다.
33
+ export function t(key, vars) {
34
+ const locale = getLocale();
35
+ const template = locale === "en" ? key : (DICTS[locale]?.[key] ?? key);
36
+ return vars ? template.replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? `{${k}}`)) : template;
37
+ }
@@ -1,6 +1,7 @@
1
- // 스피너 + raw-mode 화살표 셀렉터 — zero-dep 라인 플로우 인터랙션.
1
+ // 스피너 + raw-mode 화살표 셀렉터 — zero-dep 라인 플로우 인터랙션(키보드 전용).
2
2
 
3
3
  import { style, displayWidth, terminalWidth } from "./ansi.mjs";
4
+ import { t } from "./i18n.mjs";
4
5
 
5
6
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
7
 
@@ -43,7 +44,9 @@ export function parseKeys(chunk) {
43
44
  const code = m[2];
44
45
  if (code === "A") out.push({ type: "up" });
45
46
  else if (code === "B") out.push({ type: "down" });
46
- else out.push({ type: "unknown" }); // 좌우 화살표 등은 무시
47
+ else if (code === "C") out.push({ type: "right" });
48
+ else if (code === "D") out.push({ type: "left" });
49
+ else out.push({ type: "unknown" }); // 기타 CSI는 무시
47
50
  i += 2 + m[1].length + 1;
48
51
  continue;
49
52
  }
@@ -62,11 +65,24 @@ export function parseKeys(chunk) {
62
65
  return out;
63
66
  }
64
67
 
68
+ // 청크 경계에서 잘린 CSI 시퀀스를 다음 청크와 이어 붙이는 스트림 래퍼(순수 폐쇄 — 테스트 대상).
69
+ // 외톨이 \x1b(=ESC 키)는 즉시 escape 처리(기존 트레이드오프 유지).
70
+ export function keyFeeder() {
71
+ let carry = "";
72
+ return (chunk) => {
73
+ let data = carry + chunk;
74
+ const cut = /\x1b\[[0-9;]*$/.exec(data);
75
+ carry = cut ? cut[0] : "";
76
+ if (cut) data = data.slice(0, cut.index);
77
+ return parseKeys(data);
78
+ };
79
+ }
80
+
65
81
  // 화살표 이동 · 타이핑 필터 · Enter 선택 · Esc/Ctrl+C 취소(null).
66
82
  // 비TTY면 즉시 null — 호출자가 기존 번호 피커 등으로 폴백한다.
67
83
  // process.exit는 절대 호출하지 않는다(취소 처리는 호출자 몫).
68
84
  export async function select(items, {
69
- message = "선택",
85
+ message = t("Select"),
70
86
  formatItem = String,
71
87
  matches = (all, q) => (q ? all.filter((it) => String(formatItem(it)).toLowerCase().includes(q.toLowerCase())) : all),
72
88
  limit = 10,
@@ -92,14 +108,14 @@ export async function select(items, {
92
108
  if (cursor >= pool.length) cursor = Math.max(0, pool.length - 1);
93
109
  const shown = pool.slice(0, limit);
94
110
  const lines = [
95
- `${style.plum("?")} ${style.bold(message)} ${style.dim("타이핑=필터 · ↑↓=이동 · Enter=선택 · Esc=취소")}`,
111
+ `${style.plum("?")} ${style.bold(message)} ${style.dim(t("type to filter · ↑↓ move · Enter select · Esc cancel"))}`,
96
112
  `${style.gray("›")} ${query}`,
97
113
  ...shown.map((it, i) =>
98
114
  i === cursor ? `${style.plum("❯")} ${style.bold(formatItem(it))}` : ` ${style.dim(formatItem(it))}`,
99
115
  ),
100
116
  ];
101
- if (pool.length > shown.length) lines.push(style.dim(` … ${pool.length - shown.length}개 더 — 타이핑으로 좁히기`));
102
- if (!pool.length) lines.push(style.dim(" 일치 없음"));
117
+ if (pool.length > shown.length) lines.push(style.dim(` ${t("… {n} more — type to narrow down", { n: pool.length - shown.length })}`));
118
+ if (!pool.length) lines.push(style.dim(` ${t("No matches")}`));
103
119
  erase();
104
120
  const frame = lines.join("\n");
105
121
  output.write(frame);
@@ -117,8 +133,10 @@ export async function select(items, {
117
133
  resolve(value);
118
134
  };
119
135
 
136
+ const feed = keyFeeder();
120
137
  const onData = (chunk) => {
121
- for (const key of parseKeys(chunk)) {
138
+ let dirty = false;
139
+ for (const key of feed(chunk)) {
122
140
  if (key.type === "ctrlc" || key.type === "escape") return finish(null);
123
141
  if (key.type === "enter") {
124
142
  const pool = matches(items, query);
@@ -130,9 +148,10 @@ export async function select(items, {
130
148
  else if (key.type === "char") {
131
149
  query += key.value;
132
150
  cursor = 0;
133
- }
151
+ } else continue;
152
+ dirty = true;
134
153
  }
135
- render();
154
+ if (dirty) render();
136
155
  };
137
156
 
138
157
  input.setEncoding("utf8"); // 멀티바이트(한글) 조각 자동 재조립
package/src/lang.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // `pleum lang [code]` — 표시 언어 조회/변경. 인자 없이 TTY면 화살표 선택.
2
+ import { LANGS, getLocale, setLocale, t } from "./i18n.mjs";
3
+ import { select } from "./interactive.mjs";
4
+ import { style } from "./ansi.mjs";
5
+
6
+ export async function lang(rest = []) {
7
+ const code = rest[0];
8
+ if (code) {
9
+ if (!LANGS[code]) {
10
+ console.error(t("Unsupported language: {code}\n Available: {list}", { code, list: Object.keys(LANGS).join(", ") }));
11
+ process.exit(1);
12
+ }
13
+ setLocale(code);
14
+ console.log(`${style.green("✓")} ${t("Language set to {name}", { name: LANGS[code] })}`);
15
+ return;
16
+ }
17
+
18
+ const current = getLocale();
19
+ if (process.stdin.isTTY && process.stdout.isTTY) {
20
+ const items = Object.entries(LANGS).map(([id, name]) => ({ id, name }));
21
+ const picked = await select(items, {
22
+ message: t("Choose a language"),
23
+ formatItem: (it) => `${it.name}${it.id === current ? style.dim(` (${t("current")})`) : ""}`,
24
+ });
25
+ if (picked) {
26
+ setLocale(picked.id);
27
+ console.log(`${style.green("✓")} ${t("Language set to {name}", { name: picked.name })}`);
28
+ }
29
+ return;
30
+ }
31
+ console.log(t("Current language: {name}", { name: LANGS[current] }));
32
+ console.log(t("Available: {list}", { list: Object.entries(LANGS).map(([id, name]) => `${id} (${name})`).join(", ") }));
33
+ }
package/src/launch.mjs CHANGED
@@ -4,9 +4,10 @@ import { homedir } from "node:os";
4
4
  import { join, delimiter } from "node:path";
5
5
  import { requireKey } from "./config.mjs";
6
6
  import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
7
+ import { t } from "./i18n.mjs";
7
8
 
8
- // PATH에서 실행 파일을 찾는다(설치 여부 표시용).
9
- function onPath(bin) {
9
+ // PATH에서 실행 파일을 찾는다(설치 여부 표시용). menu.mjs도 사용.
10
+ export function onPath(bin) {
10
11
  const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
11
12
  return (process.env.PATH || "")
12
13
  .split(delimiter)
@@ -15,20 +16,22 @@ function onPath(bin) {
15
16
 
16
17
  // `pleum launch`(도구 미지정) — 지원 도구 + 설치 여부를 안내.
17
18
  export function launchHelp() {
18
- console.log("사용법: pleum launch <tool> [--model id] [-- 도구인자]\n");
19
- console.log("자동 실행:");
19
+ console.log(t("Usage: pleum launch <tool> [--model id] [-- tool args]\n"));
20
+ console.log(t("Auto-run:"));
20
21
  for (const [name, r] of Object.entries(RECIPES)) {
21
- const ok = onPath(r.bin) ? "✓ 설치됨" : `✗ 미설치 — ${r.install}`;
22
+ const ok = onPath(r.bin) ? t("✓ installed") : `${t("✗ not installed —")} ${r.install}`;
22
23
  console.log(` ${name.padEnd(11)} ${r.label.padEnd(13)} ${ok}`);
23
24
  }
24
- console.log("\n설정 안내(provider 정의 파일 필요):");
25
+ console.log(`\n${t("Manual setup (needs a provider definition file):")}`);
25
26
  for (const [name, m] of Object.entries(MANUAL)) {
26
- console.log(` ${name.padEnd(11)} ${m.label}`);
27
+ const ok = onPath(m.bin) ? t("✓ installed") : `${t("✗ not installed —")} ${m.install}`;
28
+ console.log(` ${name.padEnd(11)} ${m.label.padEnd(13)} ${ok}`);
27
29
  }
28
- console.log("\n예: pleum launch claude --model claude-sonnet-4");
30
+ console.log(`\n${t("Example: pleum launch claude --model claude-sonnet-4")}`);
29
31
  }
30
32
 
31
- export async function launch(tool, model, passthrough) {
33
+ // exit:false면 process.exit 대신 자식 종료 코드를 반환 — 런처 메뉴의 실행 후 복귀용.
34
+ export async function launch(tool, model, passthrough, { exit = true } = {}) {
32
35
  const key = requireKey();
33
36
  const r = RECIPES[tool];
34
37
 
@@ -38,14 +41,14 @@ export async function launch(tool, model, passthrough) {
38
41
  return;
39
42
  }
40
43
  console.error(
41
- `지원하지 않는 도구: ${tool}\n` +
42
- ` 자동 실행: ${Object.keys(RECIPES).join(", ")}\n` +
43
- ` 설정 안내: ${Object.keys(MANUAL).join(", ")}`,
44
+ t("Unsupported tool: {tool}", { tool }) +
45
+ `\n ${t("Auto-run:")} ${Object.keys(RECIPES).join(", ")}` +
46
+ `\n ${t("Manual setup:")} ${Object.keys(MANUAL).join(", ")}`,
44
47
  );
45
48
  process.exit(1);
46
49
  }
47
50
 
48
- const env = { ...process.env, ...r.env(key) };
51
+ const env = { ...process.env, ...r.env(key, model) };
49
52
 
50
53
  // Codex는 격리 CODEX_HOME에 config.toml을 써서 사용자 ~/.codex를 보존한다.
51
54
  if (r.type === "codex-home") {
@@ -57,28 +60,35 @@ export async function launch(tool, model, passthrough) {
57
60
 
58
61
  const args = [...r.args(model), ...passthrough];
59
62
  if (r.note) console.log(`ℹ ${r.note}`);
60
- console.log(`▶ ${r.label} 실행: ${r.bin} ${args.join(" ")}\n`);
63
+ console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: `${r.bin} ${args.join(" ")}` })}\n`);
61
64
 
62
- const child = spawn(r.bin, args, {
63
- env,
64
- stdio: "inherit",
65
- shell: process.platform === "win32",
65
+ return await new Promise((resolve) => {
66
+ const child = spawn(r.bin, args, {
67
+ env,
68
+ stdio: "inherit",
69
+ shell: process.platform === "win32",
70
+ });
71
+ child.on("error", (e) => {
72
+ if (e.code === "ENOENT") {
73
+ console.error(`✗ ${t("'{bin}' not found. Install:\n {install}", { bin: r.bin, install: r.install })}`);
74
+ if (exit) process.exit(127);
75
+ return resolve(127);
76
+ }
77
+ console.error(`✗ ${e.message}`);
78
+ if (exit) process.exit(1);
79
+ resolve(1);
80
+ });
81
+ child.on("exit", (code) => {
82
+ if (exit) process.exit(code ?? 0);
83
+ resolve(code ?? 0);
84
+ });
66
85
  });
67
- child.on("error", (e) => {
68
- if (e.code === "ENOENT") {
69
- console.error(`✗ '${r.bin}'를 찾을 수 없습니다. 설치:\n ${r.install}`);
70
- process.exit(127);
71
- }
72
- console.error(`✗ ${e.message}`);
73
- process.exit(1);
74
- });
75
- child.on("exit", (code) => process.exit(code ?? 0));
76
86
  }
77
87
 
78
88
  function printManual(tool, key) {
79
89
  const m = MANUAL[tool];
80
- console.log(`${m.label}는 provider 정의가 파일에 필요해 자동 실행 대신 설정만 안내합니다.\n`);
81
- console.log(`1) ${m.file} 에 추가:\n`);
90
+ console.log(`${t("{label} needs a provider definition in a file, so this only shows setup — it won't auto-run.", { label: m.label })}\n`);
91
+ console.log(`${t("1) Add to {file}:", { file: m.file })}\n`);
82
92
  console.log(m.snippet());
83
- console.log(`\n2) 키 export 후 실행:\n export PLEUM_API_KEY=${key}\n`);
93
+ console.log(`\n${t("2) Export the key and run:")}\n export PLEUM_API_KEY=${key}\n`);
84
94
  }