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 +2 -0
- package/bin/pleum.mjs +51 -32
- package/package.json +1 -1
- package/src/ansi.mjs +9 -2
- 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 +28 -9
- package/src/lang.mjs +33 -0
- package/src/launch.mjs +40 -30
- 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 +327 -0
- 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,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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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(
|
|
139
|
+
console.log(buildHelp());
|
|
121
140
|
break;
|
|
122
141
|
default:
|
|
123
|
-
console.error(
|
|
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
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
|
-
//
|
|
53
|
-
return String(s)
|
|
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(
|
|
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
|
|
|
@@ -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: "
|
|
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("
|
|
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(` …
|
|
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
|
-
|
|
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("
|
|
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 대신 자식 종료 코드를 반환 — 런처 메뉴의 실행 후 복귀용.
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
63
|
+
console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: `${r.bin} ${args.join(" ")}` })}\n`);
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(`${
|
|
81
|
-
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`);
|
|
82
92
|
console.log(m.snippet());
|
|
83
|
-
console.log(`\
|
|
93
|
+
console.log(`\n${t("2) Export the key and run:")}\n export PLEUM_API_KEY=${key}\n`);
|
|
84
94
|
}
|