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 +7 -5
- package/bin/pleum.mjs +54 -37
- package/package.json +1 -1
- package/src/balance.mjs +5 -4
- package/src/completion.mjs +10 -7
- package/src/config.mjs +13 -8
- package/src/health.mjs +9 -8
- package/src/i18n.mjs +37 -0
- package/src/interactive.mjs +25 -99
- package/src/lang.mjs +33 -0
- package/src/launch.mjs +18 -16
- package/src/locales/es.mjs +218 -0
- package/src/locales/ja.mjs +218 -0
- package/src/locales/ko.mjs +281 -0
- package/src/locales/zh.mjs +218 -0
- package/src/login.mjs +16 -15
- package/src/menu.mjs +129 -147
- package/src/models.mjs +12 -11
- package/src/recipes.mjs +83 -23
- package/src/run.mjs +44 -41
- package/src/settings.mjs +168 -0
- package/src/top.mjs +7 -6
- package/src/usage.mjs +10 -9
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) 인터랙티브 런처 메뉴 —
|
|
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`
|
|
73
|
-
세팅하고 바로 실행한다. 사용자의 기존 설정 파일은
|
|
73
|
+
`claude` · `aider` · `codex` · `goose` · `openhands` · `opencode` · `omp` · `hermes` · `qwen` · `copilot` —
|
|
74
|
+
env(또는 격리된 `CODEX_HOME`/임시 설정 문자열)만 세팅하고 바로 실행한다. 사용자의 기존 설정 파일은
|
|
75
|
+
건드리지 않는다.
|
|
74
76
|
|
|
75
|
-
`
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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(
|
|
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(
|
|
142
|
+
console.log(buildHelp());
|
|
126
143
|
break;
|
|
127
144
|
default:
|
|
128
|
-
console.error(
|
|
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
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(
|
|
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("
|
|
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(
|
|
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(`⚠
|
|
24
|
+
console.log(style.yellow(`⚠ ${t("Balance is below the auto-charge threshold ({threshold} PLM).", { threshold: threshold.toLocaleString("ko-KR") })}`));
|
|
24
25
|
}
|
|
25
26
|
}
|
package/src/completion.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Shell 자동완성 스크립트 출력. 명령·도구는 정적, 모델명은 `pleum models`에서 동적으로 채운다.
|
|
2
2
|
// 설치: pleum completion zsh >> ~/.zshrc · pleum completion bash >> ~/.bashrc
|
|
3
|
-
|
|
4
|
-
|
|
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)"
|
|
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)"
|
|
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
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
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("
|
|
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(
|
|
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)(
|
|
21
|
-
if (health.db != null) lines.push((isOk(health.db) ? ok : bad)(`DB ${isOk(health.db) ? "
|
|
22
|
-
if (health.redis != null) lines.push((isOk(health.redis) ? ok : bad)(`Redis ${isOk(health.redis) ? "
|
|
23
|
-
if (health.providers_configured != null) lines.push(ok(
|
|
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(
|
|
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(
|
|
34
|
-
: warn(
|
|
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
|
+
}
|
package/src/interactive.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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\[[
|
|
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("
|
|
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(` …
|
|
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;
|
|
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 === "
|
|
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
|
+
}
|