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/README.md CHANGED
@@ -29,9 +29,9 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
29
29
 
30
30
  | 명령 | 설명 |
31
31
  |------|------|
32
- | `pleum` | (인자 없음, TTY) 인터랙티브 런처 메뉴 — 채팅·에이전트 실행·잔액·리더보드를 키보드·마우스로. ↑/↓·휠 이동 · enter·클릭 실행 · →·우클릭 모델 설정 · esc 종료. 미설치 에이전트는 확인 후 자동 설치, 실행 종료 후 메뉴 복귀. 파이프에선 도움말 출력 |
32
+ | `pleum` | (인자 없음, TTY) 인터랙티브 런처 메뉴 — 키보드 전용. ↑/↓ 이동 · enter 실행 · → 모델 설정 · esc 종료(서브메뉴에선 뒤로) · ctrl+c 즉시 종료. "에이전트 실행" 진입 시 Claude Code·Codex·opencode·omp·Hermes Agent·Qwen Code·GitHub Copilot CLI 등 지원 에이전트 전체를 설치 여부와 함께 볼 수 있고, 미설치는 확인 후 자동 설치(또는 설정 파일 안내). "설정" 진입 시 언어·에이전트별 기본 모델·계정(로그인/로그아웃)·CLI 업데이트·셸 자동완성을 한곳에서 관리. 실행 종료 후 메뉴 복귀. 파이프에선 도움말 출력 |
33
33
  | `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
34
- | `pleum logout` | 저장된 키 삭제 |
34
+ | `pleum logout` | 저장된 API 키만 삭제(언어·기본 모델 등 나머지 설정은 유지) |
35
35
  | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
36
36
  | `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택 |
37
37
  | `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
@@ -40,6 +40,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
40
40
  | `pleum balance` | 크레딧 잔액 조회 |
41
41
  | `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — 최신 로그인 키 필요(`usage:read`) |
42
42
  | `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
43
+ | `pleum lang [code]` | 표시 언어 조회/변경 — `en`(기본)·`ko`·`ja`·`zh`·`es`. 인자 없이 TTY면 화살표로 선택, `PLEUM_LANG` 환경변수로도 덮어쓰기 가능 |
43
44
  | `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
44
45
  | `pleum upgrade` | 최신 버전으로 재설치 |
45
46
  | `pleum version` | 버전 출력 |
@@ -69,10 +70,11 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
69
70
 
70
71
  ## 자동 실행 지원 도구
71
72
 
72
- `claude` · `aider` · `codex` · `goose` · `openhands` — env(또는 격리된 `CODEX_HOME`)만
73
- 세팅하고 바로 실행한다. 사용자의 기존 설정 파일은 건드리지 않는다.
73
+ `claude` · `aider` · `codex` · `goose` · `openhands` · `opencode` · `omp` · `hermes` · `qwen` · `copilot` —
74
+ env(또는 격리된 `CODEX_HOME`/임시 설정 문자열)만 세팅하고 바로 실행한다. 사용자의 기존 설정 파일은
75
+ 건드리지 않는다.
74
76
 
75
- `opencode` · `crush` 는 provider 정의가 파일에 필요해 설정 스니펫만 안내한다.
77
+ `crush` 는 provider 정의가 파일에 필요해 설정 스니펫만 안내한다.
76
78
 
77
79
  ## 환경변수
78
80
 
package/bin/pleum.mjs CHANGED
@@ -10,41 +10,53 @@ import { top } from "../src/top.mjs";
10
10
  import { health } from "../src/health.mjs";
11
11
  import { completion } from "../src/completion.mjs";
12
12
  import { upgrade } from "../src/upgrade.mjs";
13
- import { clearConfig } from "../src/config.mjs";
13
+ import { readConfig, writeConfig, clearCredentials } from "../src/config.mjs";
14
14
  import { mainMenu } from "../src/menu.mjs";
15
+ import { lang } from "../src/lang.mjs";
16
+ import { RECIPES } from "../src/recipes.mjs";
17
+ import { t } from "../src/i18n.mjs";
15
18
 
16
- const HELP = `pleum — PleumRouter 터미널 CLI
19
+ // 명령 구문(왼쪽)은 로케일과 무관 — 설명(오른쪽)만 번역, 폭은 가장 긴 구문 기준으로 계산.
20
+ function buildHelp() {
21
+ const rows = [
22
+ ["pleum", t("(no args) interactive launcher menu — keyboard only (TTY only)")],
23
+ ["pleum login [--device]", t("Log in via browser + auto-save the key")],
24
+ ["", t("--device: browser-less environments (SSH/CI)")],
25
+ ["pleum logout", t("Delete the saved key")],
26
+ ["pleum launch [tool] [--model id]", t("Run a coding agent against PleumRouter")],
27
+ ["", `(${Object.keys(RECIPES).join(" · ")})`],
28
+ ["", t("omit the tool to see the supported list and install status")],
29
+ ['pleum run [model] ["prompt"]', t("Terminal chat (REPL) / one-shot / pipe")],
30
+ ["", t("omit the model for an arrow-key picker; /help lists commands")],
31
+ ["pleum models [query] [--json]", t("Model list (price, context included)")],
32
+ ["pleum models <id> --info", t("Model detail (price, perf, per-provider uptime)")],
33
+ ["pleum top [--period weekly]", t("Model leaderboard · provider share (daily|weekly|monthly)")],
34
+ ["pleum balance", t("Check your credit balance")],
35
+ ["pleum usage [--by model] [--days 30]", t("Your spend report (model|provider|api_key|day)")],
36
+ ["pleum health", t("Check gateway connectivity and login status")],
37
+ ["pleum lang [code]", t("Show/change display language (en|ko|ja|zh|es)")],
38
+ ["pleum completion <zsh|bash>", t("Print a shell completion script")],
39
+ ["pleum upgrade", t("Reinstall the latest version")],
40
+ ["pleum version", t("Print the version")],
41
+ ];
42
+ const width = Math.max(...rows.map(([cmd]) => cmd.length)) + 2;
43
+ const body = rows.map(([cmd, desc]) => ` ${cmd.padEnd(width)}${desc}`).join("\n");
17
44
 
18
- pleum (인자 없음) 인터랙티브 런처 메뉴 — 키보드·마우스 (TTY 전용)
19
- pleum login [--device] 브라우저로 로그인 + 키 자동 저장
20
- --device: 브라우저 없는 환경(SSH·CI)
21
- pleum logout 저장된 키 삭제
22
- pleum launch [tool] [--model id] 코딩 에이전트를 PleumRouter로 실행
23
- (claude · aider · codex · goose · openhands)
24
- 도구 생략 시 지원 목록·설치 여부 표시
25
- pleum run [model] ["프롬프트"] 터미널 채팅(REPL) / 1회 실행 / 파이프
26
- 모델 생략 시 화살표로 선택, /help 로 명령
27
- pleum models [query] [--json] 모델 목록(가격·컨텍스트 포함)
28
- pleum models <id> --info 모델 상세(가격·성능·provider별 uptime)
29
- pleum top [--period weekly] 모델 리더보드·제공자 점유율(daily|weekly|monthly)
30
- pleum balance 크레딧 잔액 조회
31
- pleum usage [--by model] [--days 30] 내 지출 리포트(model|provider|api_key|day)
32
- pleum health 게이트웨이 연결·로그인 상태 점검
33
- pleum completion <zsh|bash> 셸 자동완성 스크립트 출력
34
- pleum upgrade 최신 버전으로 재설치
35
- pleum version 버전 출력
45
+ const examples = [
46
+ ["pleum launch claude --model claude-sonnet-4", null],
47
+ ["pleum launch codex --model gpt-4.1 -- --full-auto", null],
48
+ ["pleum run gpt-4.1", t("interactive")],
49
+ [`pleum run gpt-4.1 "${t("Summarize in one line: ...")}"`, t("one-shot")],
50
+ [`cat error.log | pleum run gpt-4.1 "${t("what caused this?")}"`, t("pipe")],
51
+ ["pleum models deepseek/deepseek-chat --info", t("model detail")],
52
+ ["pleum usage --by day --days 7", t("last 7 days, by day")],
53
+ ["pleum login --device", t("SSH/CI environment")],
54
+ ];
55
+ const exWidth = Math.max(...examples.map(([cmd]) => cmd.length)) + 2;
56
+ const exBody = examples.map(([cmd, note]) => ` ${note ? cmd.padEnd(exWidth) + `# ${note}` : cmd}`).join("\n");
36
57
 
37
- 예:
38
- pleum launch claude --model claude-sonnet-4
39
- pleum launch codex --model gpt-4.1 -- --full-auto
40
- pleum run gpt-4.1 # 대화형
41
- pleum run gpt-4.1 "한 줄로 요약: ..." # 1회 실행
42
- cat error.log | pleum run gpt-4.1 "원인은?" # 파이프
43
- pleum models deepseek/deepseek-chat --info # 모델 상세
44
- pleum usage --by day --days 7 # 최근 1주 일별 지출
45
- pleum login --device # SSH/CI 환경
46
-
47
- base_url 덮어쓰기: PLEUM_BASE_URL 환경변수`;
58
+ 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")}`;
59
+ }
48
60
 
49
61
  // `--model id`/`-m id`를 뽑고, 나머지는 도구로 그대로 넘긴다. `--` 이후는 전부 패스스루.
50
62
  function parseLaunchArgs(args) {
@@ -72,9 +84,14 @@ try {
72
84
  if (rest.includes("--device")) await loginDevice();
73
85
  else await login();
74
86
  break;
75
- case "logout":
76
- clearConfig();
77
- console.log("✓ 로그아웃됨");
87
+ case "logout": {
88
+ const cfg = readConfig();
89
+ if (cfg) writeConfig(clearCredentials(cfg)); // 로그인 이력 없으면(cfg=null) 빈 파일을 새로 만들지 않는다
90
+ console.log(`✓ ${t("Logged out")}`);
91
+ break;
92
+ }
93
+ case "lang":
94
+ await lang(rest);
78
95
  break;
79
96
  case "launch": {
80
97
  const tool = rest[0];
@@ -117,15 +134,15 @@ try {
117
134
  break;
118
135
  case undefined:
119
136
  // TTY면 ollama풍 런처 메뉴, 파이프/스크립트면 기존 도움말.
120
- if (!(await mainMenu())) console.log(HELP);
137
+ if (!(await mainMenu())) console.log(buildHelp());
121
138
  break;
122
139
  case "-h":
123
140
  case "--help":
124
141
  case "help":
125
- console.log(HELP);
142
+ console.log(buildHelp());
126
143
  break;
127
144
  default:
128
- console.error(`알 수 없는 명령: ${cmd}\n\n${HELP}`);
145
+ console.error(`${t("Unknown command: {cmd}", { cmd })}\n\n${buildHelp()}`);
129
146
  process.exit(1);
130
147
  }
131
148
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
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,13 @@
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";
4
- const TOOLS = "claude aider codex goose openhands opencode crush";
3
+ import { t } from "./i18n.mjs";
4
+ import { RECIPES, MANUAL } from "./recipes.mjs";
5
+
6
+ const COMMANDS = "login logout lang launch run models top balance usage health version upgrade completion help";
7
+ const TOOLS = [...Object.keys(RECIPES), ...Object.keys(MANUAL)].join(" ");
5
8
 
6
9
  function bash() {
7
- return `# pleum bash completion — eval "$(pleum completion bash)" 또는 ~/.bashrc에 추가
10
+ return `# pleum bash completion — eval "$(pleum completion bash)" (${t("or add to ~/.bashrc")})
8
11
  _pleum() {
9
12
  local cur prev; cur="\${COMP_WORDS[COMP_CWORD]}"; prev="\${COMP_WORDS[COMP_CWORD-1]}"
10
13
  if [ "$COMP_CWORD" -eq 1 ]; then
@@ -21,7 +24,7 @@ complete -F _pleum pleum`;
21
24
 
22
25
  function zsh() {
23
26
  return `#compdef pleum
24
- # pleum zsh completion — eval "$(pleum completion zsh)" 또는 fpath에 _pleum로 추가
27
+ # pleum zsh completion — eval "$(pleum completion zsh)" (${t("or add _pleum to fpath")})
25
28
  _pleum() {
26
29
  if (( CURRENT == 2 )); then
27
30
  compadd ${COMMANDS}; return
@@ -39,9 +42,9 @@ export function completion(shell) {
39
42
  if (shell === "bash") return void console.log(bash());
40
43
  if (shell === "zsh") return void console.log(zsh());
41
44
  console.error(
42
- "사용법: pleum completion <zsh|bash>\n" +
43
- ' zsh: eval "$(pleum completion zsh)" (또는 ~/.zshrc에 추가)\n' +
44
- ' bash: eval "$(pleum completion bash)" (또는 ~/.bashrc에 추가)',
45
+ t("Usage: pleum completion <zsh|bash>") + "\n" +
46
+ ` zsh: eval "$(pleum completion zsh)" (${t("or add to ~/.zshrc")})\n` +
47
+ ` bash: eval "$(pleum completion bash)" (${t("or add to ~/.bashrc")})`,
45
48
  );
46
49
  process.exit(1);
47
50
  }
package/src/config.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import { readFileSync, writeFileSync, mkdirSync } 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")
@@ -28,19 +29,23 @@ export function writeConfig(cfg) {
28
29
  writeFileSync(FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
29
30
  }
30
31
 
31
- export function clearConfig() {
32
- try {
33
- rmSync(FILE);
34
- } catch {
35
- // 이미 없음 — 무시
36
- }
32
+ // 로그인 저장 — 기존 설정(lang·launch_models·last_selection)을 보존한 채 자격증명만 병합.
33
+ // login/loginDevice가 매번 writeConfig({key, base_url})로 전체를 덮어쓰던 버그의 수정.
34
+ export function mergeLoginConfig(existing, patch) {
35
+ return { ...(existing ?? {}), ...patch };
36
+ }
37
+
38
+ // 로그아웃 — key·base_url(자격증명)만 제거. lang·launch_models 등 나머지 설정은 보존.
39
+ export function clearCredentials(existing) {
40
+ const { key, base_url, ...rest } = existing ?? {};
41
+ return rest;
37
42
  }
38
43
 
39
44
  // 키가 필요한 명령의 공통 가드. 없으면 안내 후 종료.
40
45
  export function requireKey() {
41
46
  const cfg = readConfig();
42
47
  if (!cfg?.key) {
43
- console.error("로그인이 필요합니다. 먼저 실행하세요:\n pleum login");
48
+ console.error(t("Login required. Run this first:\n pleum login"));
44
49
  process.exit(1);
45
50
  }
46
51
  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,21 @@
1
- // 스피너 + raw-mode 화살표 셀렉터 — zero-dep 라인 플로우 인터랙션.
1
+ // 스피너 + raw-mode 화살표 셀렉터 — zero-dep 라인 플로우 인터랙션(키보드 전용).
2
2
 
3
+ import { createInterface } from "node:readline";
3
4
  import { style, displayWidth, terminalWidth } from "./ansi.mjs";
5
+ import { t } from "./i18n.mjs";
6
+
7
+ // Y/n 확인. message는 "[Y/n] " 접미사까지 포함한 완성 문자열(ensureInstalled 관례 —
8
+ // 로케일별 표기를 번역 쪽이 소유). 비TTY면 프롬프트 없이 defaultYes 즉시 반환
9
+ // (select()의 비TTY 가드와 동일 패턴 — 테스트 용이성 겸용).
10
+ export async function confirm(message, { defaultYes = true, input = process.stdin, output = process.stdout } = {}) {
11
+ if (!input.isTTY || !output.isTTY) return defaultYes;
12
+ const rl = createInterface({ input, output });
13
+ const ans = await new Promise((res) => rl.question(message, res));
14
+ rl.close();
15
+ const a = ans.trim().toLowerCase();
16
+ if (!a) return defaultYes;
17
+ return a.startsWith("y");
18
+ }
4
19
 
5
20
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
21
 
@@ -27,17 +42,10 @@ export function spinner(text, { stream = process.stderr, intervalMs = 80 } = {})
27
42
  };
28
43
  }
29
44
 
30
- // 마우스 리포팅 켬/끔 — bubbletea와 동일 조합(1002 버튼추적 + 1003 모든모션 + 1006 SGR 좌표).
31
- // 미지원 모드는 터미널이 무시하므로 셋 다 켜고, 끌 때도 셋 다 리셋한다.
32
- export const MOUSE_ON = "\x1b[?1002h\x1b[?1003h\x1b[?1006h";
33
- export const MOUSE_OFF = "\x1b[?1002l\x1b[?1003l\x1b[?1006l";
34
-
35
45
  // raw-mode 입력 청크 → 키 이벤트 배열(순수 함수 — 테스트 대상).
36
46
  // 화살표(\x1b[A/B)와 일반 문자가 한 청크에 섞여 와도 순서대로 분해한다.
37
47
  // 청크 끝의 외톨이 ESC는 escape(취소)로 처리 — 실터미널은 화살표 3바이트를
38
48
  // 한 이벤트로 보내므로 실사용에서 오인될 일은 거의 없다.
39
- // SGR 마우스(\x1b[<Cb;Cx;CyM|m): Cb 비트 0-1=버튼(0좌 2우), 32=모션, 64=휠(64업 65다운).
40
- // CPR(\x1b[row;colR): DSR \x1b[6n 응답 — 인라인 렌더의 마우스 좌표 매핑용.
41
49
  export function parseKeys(chunk) {
42
50
  const out = [];
43
51
  for (let i = 0; i < chunk.length; ) {
@@ -45,21 +53,6 @@ export function parseKeys(chunk) {
45
53
  if (ch === "\x1b") {
46
54
  if (chunk[i + 1] === "[") {
47
55
  const rest = chunk.slice(i + 2);
48
- const mouse = /^<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
49
- if (mouse) {
50
- const cb = Number(mouse[1]);
51
- out.push({
52
- type: "mouse",
53
- btn: cb & 0b11,
54
- x: Number(mouse[2]),
55
- y: Number(mouse[3]),
56
- motion: Boolean(cb & 32),
57
- release: mouse[4] === "m",
58
- wheel: cb & 64 ? ((cb & 0b11) === 0 ? "up" : (cb & 0b11) === 1 ? "down" : null) : null,
59
- });
60
- i += 2 + mouse[0].length;
61
- continue;
62
- }
63
56
  const m = /^([0-9;]*)([A-Za-z~])/.exec(rest);
64
57
  if (m) {
65
58
  const code = m[2];
@@ -67,10 +60,7 @@ export function parseKeys(chunk) {
67
60
  else if (code === "B") out.push({ type: "down" });
68
61
  else if (code === "C") out.push({ type: "right" });
69
62
  else if (code === "D") out.push({ type: "left" });
70
- else if (code === "R") {
71
- const [row, col] = m[1].split(";").map(Number);
72
- out.push({ type: "cpr", row: row || 0, col: col || 0 });
73
- } else out.push({ type: "unknown" }); // 기타 CSI는 무시
63
+ else out.push({ type: "unknown" }); // 기타 CSI는 무시
74
64
  i += 2 + m[1].length + 1;
75
65
  continue;
76
66
  }
@@ -89,56 +79,24 @@ export function parseKeys(chunk) {
89
79
  return out;
90
80
  }
91
81
 
92
- // 청크 경계에서 잘린 CSI/SGR 시퀀스를 다음 청크와 이어 붙이는 스트림 래퍼(순수 폐쇄 — 테스트 대상).
93
- // 1003 모션 홍수에서 \x1b[<… 가 중간에 잘리면 escape로 오인돼 UI가 닫히는 것을 방지.
82
+ // 청크 경계에서 잘린 CSI 시퀀스를 다음 청크와 이어 붙이는 스트림 래퍼(순수 폐쇄 — 테스트 대상).
94
83
  // 외톨이 \x1b(=ESC 키)는 즉시 escape 처리(기존 트레이드오프 유지).
95
84
  export function keyFeeder() {
96
85
  let carry = "";
97
86
  return (chunk) => {
98
87
  let data = carry + chunk;
99
- const cut = /\x1b\[[<0-9;]*$/.exec(data);
88
+ const cut = /\x1b\[[0-9;]*$/.exec(data);
100
89
  carry = cut ? cut[0] : "";
101
90
  if (cut) data = data.slice(0, cut.index);
102
91
  return parseKeys(data);
103
92
  };
104
93
  }
105
94
 
106
- // 절대 y(1-기반)를 인라인 프레임의 라인 인덱스로 변환(순수 함수 — 테스트 대상).
107
- // endRow = 프레임 마지막 줄의 절대 행(CPR로 얻음). 줄바꿈은 rowsFor(라인별 시각 행수)로 반영.
108
- export function lineIndexAt(y, endRow, lines, rowsFor) {
109
- if (!endRow) return -1;
110
- const total = lines.reduce((n, l) => n + rowsFor(l), 0);
111
- const top = endRow - total + 1;
112
- if (y < top || y > endRow) return -1;
113
- let row = top;
114
- for (let i = 0; i < lines.length; i++) {
115
- const h = rowsFor(lines[i]);
116
- if (y < row + h) return i;
117
- row += h;
118
- }
119
- return -1;
120
- }
121
-
122
- // 마우스 리포팅 토글 + 비정상 종료 안전망(프로세스 exit 시 터미널 모드 복원).
123
- let mouseActive = false;
124
- let exitHooked = false;
125
- export function setMouse(output, on) {
126
- if (!exitHooked) {
127
- exitHooked = true;
128
- process.on("exit", () => {
129
- if (mouseActive) process.stdout.write(MOUSE_OFF + "\x1b[?25h");
130
- });
131
- }
132
- mouseActive = on;
133
- output.write(on ? MOUSE_ON : MOUSE_OFF);
134
- }
135
-
136
95
  // 화살표 이동 · 타이핑 필터 · Enter 선택 · Esc/Ctrl+C 취소(null).
137
- // 마우스: 호버=하이라이트, 휠=이동, 좌클릭=선택.
138
96
  // 비TTY면 즉시 null — 호출자가 기존 번호 피커 등으로 폴백한다.
139
97
  // process.exit는 절대 호출하지 않는다(취소 처리는 호출자 몫).
140
98
  export async function select(items, {
141
- message = "선택",
99
+ message = t("Select"),
142
100
  formatItem = String,
143
101
  matches = (all, q) => (q ? all.filter((it) => String(formatItem(it)).toLowerCase().includes(q.toLowerCase())) : all),
144
102
  limit = 10,
@@ -151,9 +109,6 @@ export async function select(items, {
151
109
  let query = "";
152
110
  let cursor = 0;
153
111
  let lastRows = 0;
154
- let lastLines = [];
155
- let shownCount = 0;
156
- let cprRow = 0; // 프레임 마지막 줄의 절대 행 — DSR \x1b[6n 응답으로 갱신
157
112
 
158
113
  const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
159
114
  const erase = () => {
@@ -167,35 +122,25 @@ export async function select(items, {
167
122
  if (cursor >= pool.length) cursor = Math.max(0, pool.length - 1);
168
123
  const shown = pool.slice(0, limit);
169
124
  const lines = [
170
- `${style.plum("?")} ${style.bold(message)} ${style.dim("타이핑=필터 · ↑↓=이동 · Enter=선택 · Esc=취소")}`,
125
+ `${style.plum("?")} ${style.bold(message)} ${style.dim(t("type to filter · ↑↓ move · Enter select · Esc cancel"))}`,
171
126
  `${style.gray("›")} ${query}`,
172
127
  ...shown.map((it, i) =>
173
128
  i === cursor ? `${style.plum("❯")} ${style.bold(formatItem(it))}` : ` ${style.dim(formatItem(it))}`,
174
129
  ),
175
130
  ];
176
- if (pool.length > shown.length) lines.push(style.dim(` … ${pool.length - shown.length}개 더 — 타이핑으로 좁히기`));
177
- if (!pool.length) lines.push(style.dim(" 일치 없음"));
131
+ if (pool.length > shown.length) lines.push(style.dim(` ${t("… {n} more — type to narrow down", { n: pool.length - shown.length })}`));
132
+ if (!pool.length) lines.push(style.dim(` ${t("No matches")}`));
178
133
  erase();
179
134
  const frame = lines.join("\n");
180
135
  output.write(frame);
181
136
  lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
182
- lastLines = lines;
183
- shownCount = shown.length;
184
- output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
185
137
  return pool;
186
138
  };
187
139
 
188
- // 클릭/호버 y좌표 → 표시 중인 항목 인덱스(항목 라인은 3번째 줄부터). 밖이면 -1.
189
- const itemAt = (y) => {
190
- const li = lineIndexAt(y, cprRow, lastLines, rowsFor);
191
- return li >= 2 && li < 2 + shownCount ? li - 2 : -1;
192
- };
193
-
194
140
  const finish = (value) => {
195
141
  input.off("data", onData);
196
142
  input.setRawMode(false);
197
143
  input.pause();
198
- setMouse(output, false);
199
144
  output.write("\x1b[?25h");
200
145
  erase();
201
146
  if (value != null) output.write(`${style.green("✓")} ${formatItem(value)}\n`);
@@ -204,32 +149,14 @@ export async function select(items, {
204
149
 
205
150
  const feed = keyFeeder();
206
151
  const onData = (chunk) => {
207
- let dirty = false; // CPR만 온 턴에 재렌더하면 렌더→CPR 질의→응답 무한 루프
152
+ let dirty = false;
208
153
  for (const key of feed(chunk)) {
209
154
  if (key.type === "ctrlc" || key.type === "escape") return finish(null);
210
- if (key.type === "cpr") {
211
- cprRow = key.row;
212
- continue;
213
- }
214
155
  if (key.type === "enter") {
215
156
  const pool = matches(items, query);
216
157
  return finish(pool[cursor] ?? null);
217
158
  }
218
- if (key.type === "mouse") {
219
- if (key.wheel === "up") cursor = Math.max(0, cursor - 1);
220
- else if (key.wheel === "down") cursor = Math.min(limit - 1, cursor + 1);
221
- else if (key.motion) {
222
- const idx = itemAt(key.y);
223
- if (idx < 0 || idx === cursor) continue;
224
- cursor = idx;
225
- } else if (key.btn === 0 && !key.release) {
226
- const idx = itemAt(key.y);
227
- if (idx < 0) continue;
228
- cursor = idx;
229
- const pool = matches(items, query);
230
- return finish(pool[cursor] ?? null);
231
- } else continue;
232
- } else if (key.type === "up") cursor = Math.max(0, cursor - 1);
159
+ if (key.type === "up") cursor = Math.max(0, cursor - 1);
233
160
  else if (key.type === "down") cursor = Math.min(limit - 1, cursor + 1);
234
161
  else if (key.type === "backspace") query = query.slice(0, -1);
235
162
  else if (key.type === "char") {
@@ -245,7 +172,6 @@ export async function select(items, {
245
172
  input.setRawMode(true);
246
173
  input.resume();
247
174
  output.write("\x1b[?25l");
248
- setMouse(output, true);
249
175
  input.on("data", onData);
250
176
  render();
251
177
  });
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
+ }