pleumrouter 0.3.1 → 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 +2 -1
- package/bin/pleum.mjs +48 -34
- package/package.json +1 -1
- package/src/balance.mjs +5 -4
- package/src/completion.mjs +8 -6
- package/src/config.mjs +2 -1
- package/src/health.mjs +9 -8
- package/src/i18n.mjs +37 -0
- package/src/interactive.mjs +11 -99
- package/src/lang.mjs +33 -0
- package/src/launch.mjs +17 -15
- package/src/locales/es.mjs +201 -0
- package/src/locales/ja.mjs +201 -0
- package/src/locales/ko.mjs +261 -0
- package/src/locales/zh.mjs +201 -0
- package/src/login.mjs +13 -12
- package/src/menu.mjs +113 -146
- package/src/models.mjs +12 -11
- package/src/recipes.mjs +81 -23
- package/src/run.mjs +41 -40
- package/src/top.mjs +7 -6
- package/src/usage.mjs +10 -9
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ 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 등 지원 에이전트 전체를 설치 여부와 함께 볼 수 있고, 미설치는 확인 후 자동 설치(또는 설정 파일 안내). 실행 종료 후 메뉴 복귀. 파이프에선 도움말 출력 |
|
|
33
33
|
| `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
|
|
34
34
|
| `pleum logout` | 저장된 키 삭제 |
|
|
35
35
|
| `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
|
|
@@ -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` | 버전 출력 |
|
package/bin/pleum.mjs
CHANGED
|
@@ -12,39 +12,50 @@ import { completion } from "../src/completion.mjs";
|
|
|
12
12
|
import { upgrade } from "../src/upgrade.mjs";
|
|
13
13
|
import { clearConfig } from "../src/config.mjs";
|
|
14
14
|
import { mainMenu } from "../src/menu.mjs";
|
|
15
|
+
import { lang } from "../src/lang.mjs";
|
|
16
|
+
import { t } from "../src/i18n.mjs";
|
|
15
17
|
|
|
16
|
-
|
|
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");
|
|
17
43
|
|
|
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 버전 출력
|
|
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");
|
|
36
56
|
|
|
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 환경변수`;
|
|
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
|
+
}
|
|
48
59
|
|
|
49
60
|
// `--model id`/`-m id`를 뽑고, 나머지는 도구로 그대로 넘긴다. `--` 이후는 전부 패스스루.
|
|
50
61
|
function parseLaunchArgs(args) {
|
|
@@ -74,7 +85,10 @@ try {
|
|
|
74
85
|
break;
|
|
75
86
|
case "logout":
|
|
76
87
|
clearConfig();
|
|
77
|
-
console.log("
|
|
88
|
+
console.log(`✓ ${t("Logged out")}`);
|
|
89
|
+
break;
|
|
90
|
+
case "lang":
|
|
91
|
+
await lang(rest);
|
|
78
92
|
break;
|
|
79
93
|
case "launch": {
|
|
80
94
|
const tool = rest[0];
|
|
@@ -117,15 +131,15 @@ try {
|
|
|
117
131
|
break;
|
|
118
132
|
case undefined:
|
|
119
133
|
// TTY면 ollama풍 런처 메뉴, 파이프/스크립트면 기존 도움말.
|
|
120
|
-
if (!(await mainMenu())) console.log(
|
|
134
|
+
if (!(await mainMenu())) console.log(buildHelp());
|
|
121
135
|
break;
|
|
122
136
|
case "-h":
|
|
123
137
|
case "--help":
|
|
124
138
|
case "help":
|
|
125
|
-
console.log(
|
|
139
|
+
console.log(buildHelp());
|
|
126
140
|
break;
|
|
127
141
|
default:
|
|
128
|
-
console.error(
|
|
142
|
+
console.error(`${t("Unknown command: {cmd}", { cmd })}\n\n${buildHelp()}`);
|
|
129
143
|
process.exit(1);
|
|
130
144
|
}
|
|
131
145
|
} 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,12 @@
|
|
|
1
1
|
// Shell 자동완성 스크립트 출력. 명령·도구는 정적, 모델명은 `pleum models`에서 동적으로 채운다.
|
|
2
2
|
// 설치: pleum completion zsh >> ~/.zshrc · pleum completion bash >> ~/.bashrc
|
|
3
|
-
|
|
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)"
|
|
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)"
|
|
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
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
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("
|
|
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(
|
|
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,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
|
|
|
@@ -27,17 +28,10 @@ export function spinner(text, { stream = process.stderr, intervalMs = 80 } = {})
|
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
|
|
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
31
|
// raw-mode 입력 청크 → 키 이벤트 배열(순수 함수 — 테스트 대상).
|
|
36
32
|
// 화살표(\x1b[A/B)와 일반 문자가 한 청크에 섞여 와도 순서대로 분해한다.
|
|
37
33
|
// 청크 끝의 외톨이 ESC는 escape(취소)로 처리 — 실터미널은 화살표 3바이트를
|
|
38
34
|
// 한 이벤트로 보내므로 실사용에서 오인될 일은 거의 없다.
|
|
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
35
|
export function parseKeys(chunk) {
|
|
42
36
|
const out = [];
|
|
43
37
|
for (let i = 0; i < chunk.length; ) {
|
|
@@ -45,21 +39,6 @@ export function parseKeys(chunk) {
|
|
|
45
39
|
if (ch === "\x1b") {
|
|
46
40
|
if (chunk[i + 1] === "[") {
|
|
47
41
|
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
42
|
const m = /^([0-9;]*)([A-Za-z~])/.exec(rest);
|
|
64
43
|
if (m) {
|
|
65
44
|
const code = m[2];
|
|
@@ -67,10 +46,7 @@ export function parseKeys(chunk) {
|
|
|
67
46
|
else if (code === "B") out.push({ type: "down" });
|
|
68
47
|
else if (code === "C") out.push({ type: "right" });
|
|
69
48
|
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는 무시
|
|
49
|
+
else out.push({ type: "unknown" }); // 기타 CSI는 무시
|
|
74
50
|
i += 2 + m[1].length + 1;
|
|
75
51
|
continue;
|
|
76
52
|
}
|
|
@@ -89,56 +65,24 @@ export function parseKeys(chunk) {
|
|
|
89
65
|
return out;
|
|
90
66
|
}
|
|
91
67
|
|
|
92
|
-
// 청크 경계에서 잘린 CSI
|
|
93
|
-
// 1003 모션 홍수에서 \x1b[<… 가 중간에 잘리면 escape로 오인돼 UI가 닫히는 것을 방지.
|
|
68
|
+
// 청크 경계에서 잘린 CSI 시퀀스를 다음 청크와 이어 붙이는 스트림 래퍼(순수 폐쇄 — 테스트 대상).
|
|
94
69
|
// 외톨이 \x1b(=ESC 키)는 즉시 escape 처리(기존 트레이드오프 유지).
|
|
95
70
|
export function keyFeeder() {
|
|
96
71
|
let carry = "";
|
|
97
72
|
return (chunk) => {
|
|
98
73
|
let data = carry + chunk;
|
|
99
|
-
const cut = /\x1b\[[
|
|
74
|
+
const cut = /\x1b\[[0-9;]*$/.exec(data);
|
|
100
75
|
carry = cut ? cut[0] : "";
|
|
101
76
|
if (cut) data = data.slice(0, cut.index);
|
|
102
77
|
return parseKeys(data);
|
|
103
78
|
};
|
|
104
79
|
}
|
|
105
80
|
|
|
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
81
|
// 화살표 이동 · 타이핑 필터 · Enter 선택 · Esc/Ctrl+C 취소(null).
|
|
137
|
-
// 마우스: 호버=하이라이트, 휠=이동, 좌클릭=선택.
|
|
138
82
|
// 비TTY면 즉시 null — 호출자가 기존 번호 피커 등으로 폴백한다.
|
|
139
83
|
// process.exit는 절대 호출하지 않는다(취소 처리는 호출자 몫).
|
|
140
84
|
export async function select(items, {
|
|
141
|
-
message = "
|
|
85
|
+
message = t("Select"),
|
|
142
86
|
formatItem = String,
|
|
143
87
|
matches = (all, q) => (q ? all.filter((it) => String(formatItem(it)).toLowerCase().includes(q.toLowerCase())) : all),
|
|
144
88
|
limit = 10,
|
|
@@ -151,9 +95,6 @@ export async function select(items, {
|
|
|
151
95
|
let query = "";
|
|
152
96
|
let cursor = 0;
|
|
153
97
|
let lastRows = 0;
|
|
154
|
-
let lastLines = [];
|
|
155
|
-
let shownCount = 0;
|
|
156
|
-
let cprRow = 0; // 프레임 마지막 줄의 절대 행 — DSR \x1b[6n 응답으로 갱신
|
|
157
98
|
|
|
158
99
|
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
159
100
|
const erase = () => {
|
|
@@ -167,35 +108,25 @@ export async function select(items, {
|
|
|
167
108
|
if (cursor >= pool.length) cursor = Math.max(0, pool.length - 1);
|
|
168
109
|
const shown = pool.slice(0, limit);
|
|
169
110
|
const lines = [
|
|
170
|
-
`${style.plum("?")} ${style.bold(message)} ${style.dim("
|
|
111
|
+
`${style.plum("?")} ${style.bold(message)} ${style.dim(t("type to filter · ↑↓ move · Enter select · Esc cancel"))}`,
|
|
171
112
|
`${style.gray("›")} ${query}`,
|
|
172
113
|
...shown.map((it, i) =>
|
|
173
114
|
i === cursor ? `${style.plum("❯")} ${style.bold(formatItem(it))}` : ` ${style.dim(formatItem(it))}`,
|
|
174
115
|
),
|
|
175
116
|
];
|
|
176
|
-
if (pool.length > shown.length) lines.push(style.dim(` …
|
|
177
|
-
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")}`));
|
|
178
119
|
erase();
|
|
179
120
|
const frame = lines.join("\n");
|
|
180
121
|
output.write(frame);
|
|
181
122
|
lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
|
|
182
|
-
lastLines = lines;
|
|
183
|
-
shownCount = shown.length;
|
|
184
|
-
output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
|
|
185
123
|
return pool;
|
|
186
124
|
};
|
|
187
125
|
|
|
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
126
|
const finish = (value) => {
|
|
195
127
|
input.off("data", onData);
|
|
196
128
|
input.setRawMode(false);
|
|
197
129
|
input.pause();
|
|
198
|
-
setMouse(output, false);
|
|
199
130
|
output.write("\x1b[?25h");
|
|
200
131
|
erase();
|
|
201
132
|
if (value != null) output.write(`${style.green("✓")} ${formatItem(value)}\n`);
|
|
@@ -204,32 +135,14 @@ export async function select(items, {
|
|
|
204
135
|
|
|
205
136
|
const feed = keyFeeder();
|
|
206
137
|
const onData = (chunk) => {
|
|
207
|
-
let dirty = false;
|
|
138
|
+
let dirty = false;
|
|
208
139
|
for (const key of feed(chunk)) {
|
|
209
140
|
if (key.type === "ctrlc" || key.type === "escape") return finish(null);
|
|
210
|
-
if (key.type === "cpr") {
|
|
211
|
-
cprRow = key.row;
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
141
|
if (key.type === "enter") {
|
|
215
142
|
const pool = matches(items, query);
|
|
216
143
|
return finish(pool[cursor] ?? null);
|
|
217
144
|
}
|
|
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);
|
|
145
|
+
if (key.type === "up") cursor = Math.max(0, cursor - 1);
|
|
233
146
|
else if (key.type === "down") cursor = Math.min(limit - 1, cursor + 1);
|
|
234
147
|
else if (key.type === "backspace") query = query.slice(0, -1);
|
|
235
148
|
else if (key.type === "char") {
|
|
@@ -245,7 +158,6 @@ export async function select(items, {
|
|
|
245
158
|
input.setRawMode(true);
|
|
246
159
|
input.resume();
|
|
247
160
|
output.write("\x1b[?25l");
|
|
248
|
-
setMouse(output, true);
|
|
249
161
|
input.on("data", onData);
|
|
250
162
|
render();
|
|
251
163
|
});
|
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,6 +4,7 @@ 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
9
|
// PATH에서 실행 파일을 찾는다(설치 여부 표시용). menu.mjs도 사용.
|
|
9
10
|
export function onPath(bin) {
|
|
@@ -15,17 +16,18 @@ export function onPath(bin) {
|
|
|
15
16
|
|
|
16
17
|
// `pleum launch`(도구 미지정) — 지원 도구 + 설치 여부를 안내.
|
|
17
18
|
export function launchHelp() {
|
|
18
|
-
console.log("
|
|
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) ? "✓
|
|
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("
|
|
25
|
+
console.log(`\n${t("Manual setup (needs a provider definition file):")}`);
|
|
25
26
|
for (const [name, m] of Object.entries(MANUAL)) {
|
|
26
|
-
|
|
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("
|
|
30
|
+
console.log(`\n${t("Example: pleum launch claude --model claude-sonnet-4")}`);
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// exit:false면 process.exit 대신 자식 종료 코드를 반환 — 런처 메뉴의 실행 후 복귀용.
|
|
@@ -39,14 +41,14 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
|
39
41
|
return;
|
|
40
42
|
}
|
|
41
43
|
console.error(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
t("Unsupported tool: {tool}", { tool }) +
|
|
45
|
+
`\n ${t("Auto-run:")} ${Object.keys(RECIPES).join(", ")}` +
|
|
46
|
+
`\n ${t("Manual setup:")} ${Object.keys(MANUAL).join(", ")}`,
|
|
45
47
|
);
|
|
46
48
|
process.exit(1);
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
const env = { ...process.env, ...r.env(key) };
|
|
51
|
+
const env = { ...process.env, ...r.env(key, model) };
|
|
50
52
|
|
|
51
53
|
// Codex는 격리 CODEX_HOME에 config.toml을 써서 사용자 ~/.codex를 보존한다.
|
|
52
54
|
if (r.type === "codex-home") {
|
|
@@ -58,7 +60,7 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
|
58
60
|
|
|
59
61
|
const args = [...r.args(model), ...passthrough];
|
|
60
62
|
if (r.note) console.log(`ℹ ${r.note}`);
|
|
61
|
-
console.log(`▶ ${r.label
|
|
63
|
+
console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: `${r.bin} ${args.join(" ")}` })}\n`);
|
|
62
64
|
|
|
63
65
|
return await new Promise((resolve) => {
|
|
64
66
|
const child = spawn(r.bin, args, {
|
|
@@ -68,7 +70,7 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
|
68
70
|
});
|
|
69
71
|
child.on("error", (e) => {
|
|
70
72
|
if (e.code === "ENOENT") {
|
|
71
|
-
console.error(`✗
|
|
73
|
+
console.error(`✗ ${t("'{bin}' not found. Install:\n {install}", { bin: r.bin, install: r.install })}`);
|
|
72
74
|
if (exit) process.exit(127);
|
|
73
75
|
return resolve(127);
|
|
74
76
|
}
|
|
@@ -85,8 +87,8 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
|
85
87
|
|
|
86
88
|
function printManual(tool, key) {
|
|
87
89
|
const m = MANUAL[tool];
|
|
88
|
-
console.log(`${
|
|
89
|
-
console.log(
|
|
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`);
|
|
90
92
|
console.log(m.snippet());
|
|
91
|
-
console.log(`\
|
|
93
|
+
console.log(`\n${t("2) Export the key and run:")}\n export PLEUM_API_KEY=${key}\n`);
|
|
92
94
|
}
|