pleumrouter 0.2.2 → 0.3.1
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 +11 -2
- package/bin/pleum.mjs +24 -1
- package/package.json +1 -1
- package/src/ansi.mjs +102 -0
- package/src/balance.mjs +12 -5
- package/src/completion.mjs +1 -1
- package/src/health.mjs +55 -0
- package/src/interactive.mjs +252 -0
- package/src/launch.mjs +24 -16
- package/src/markdown.mjs +91 -0
- package/src/menu.mjs +360 -0
- package/src/models.mjs +102 -11
- package/src/run.mjs +184 -75
- package/src/top.mjs +72 -0
- package/src/usage.mjs +69 -0
package/README.md
CHANGED
|
@@ -29,12 +29,17 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
29
29
|
|
|
30
30
|
| 명령 | 설명 |
|
|
31
31
|
|------|------|
|
|
32
|
+
| `pleum` | (인자 없음, TTY) 인터랙티브 런처 메뉴 — 채팅·에이전트 실행·잔액·리더보드를 키보드·마우스로. ↑/↓·휠 이동 · enter·클릭 실행 · →·우클릭 모델 설정 · esc 종료. 미설치 에이전트는 확인 후 자동 설치, 실행 종료 후 메뉴 복귀. 파이프에선 도움말 출력 |
|
|
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]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
|
|
35
|
-
| `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시
|
|
36
|
+
| `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택 |
|
|
36
37
|
| `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
|
|
38
|
+
| `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
|
|
39
|
+
| `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
|
|
37
40
|
| `pleum balance` | 크레딧 잔액 조회 |
|
|
41
|
+
| `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — 최신 로그인 키 필요(`usage:read`) |
|
|
42
|
+
| `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
|
|
38
43
|
| `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
|
|
39
44
|
| `pleum upgrade` | 최신 버전으로 재설치 |
|
|
40
45
|
| `pleum version` | 버전 출력 |
|
|
@@ -46,7 +51,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
46
51
|
| 명령 | 설명 |
|
|
47
52
|
|------|------|
|
|
48
53
|
| `/help`, `/?` | 명령 도움말 |
|
|
49
|
-
| `/model [id]` | **세션 유지한 채 모델 전환**(
|
|
54
|
+
| `/model [id]` | **세션 유지한 채 모델 전환**(인자 없으면 화살표 셀렉터) |
|
|
50
55
|
| `/system [텍스트]` | 시스템 프롬프트 설정/확인 |
|
|
51
56
|
| `/clear` | 대화 컨텍스트 초기화 |
|
|
52
57
|
| `/models [검색어]` | 모델 목록 보기 |
|
|
@@ -54,9 +59,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
54
59
|
| `/verbose` | 토큰·속도 통계 토글 |
|
|
55
60
|
| `/exit`, `/quit` | 종료 |
|
|
56
61
|
|
|
62
|
+
- 응답은 **마크다운 스트리밍 렌더**(굵게·코드블록 하이라이트·리스트) — 파이프로 빼면 자동으로 원문 그대로
|
|
63
|
+
- 응답 후 비용이 PLM으로 표시(스트리밍 마지막 청크 기준)
|
|
64
|
+
- **Ctrl+C**: 생성 중엔 1회로 중단(대화 유지), 대기 중엔 2초 내 2회로 종료
|
|
57
65
|
- 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
|
|
58
66
|
- 이미지: 메시지에 이미지 파일 경로를 그대로 포함 (예: `이 그림 설명해줘 ./shot.png`)
|
|
59
67
|
- `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
|
|
68
|
+
- 색상 끄기: `NO_COLOR=1` (비TTY·파이프에선 자동 무색)
|
|
60
69
|
|
|
61
70
|
## 자동 실행 지원 도구
|
|
62
71
|
|
package/bin/pleum.mjs
CHANGED
|
@@ -5,12 +5,17 @@ import { launch, launchHelp } from "../src/launch.mjs";
|
|
|
5
5
|
import { run } from "../src/run.mjs";
|
|
6
6
|
import { listModels } from "../src/models.mjs";
|
|
7
7
|
import { balance } from "../src/balance.mjs";
|
|
8
|
+
import { usage } from "../src/usage.mjs";
|
|
9
|
+
import { top } from "../src/top.mjs";
|
|
10
|
+
import { health } from "../src/health.mjs";
|
|
8
11
|
import { completion } from "../src/completion.mjs";
|
|
9
12
|
import { upgrade } from "../src/upgrade.mjs";
|
|
10
13
|
import { clearConfig } from "../src/config.mjs";
|
|
14
|
+
import { mainMenu } from "../src/menu.mjs";
|
|
11
15
|
|
|
12
16
|
const HELP = `pleum — PleumRouter 터미널 CLI
|
|
13
17
|
|
|
18
|
+
pleum (인자 없음) 인터랙티브 런처 메뉴 — 키보드·마우스 (TTY 전용)
|
|
14
19
|
pleum login [--device] 브라우저로 로그인 + 키 자동 저장
|
|
15
20
|
--device: 브라우저 없는 환경(SSH·CI)
|
|
16
21
|
pleum logout 저장된 키 삭제
|
|
@@ -18,9 +23,13 @@ const HELP = `pleum — PleumRouter 터미널 CLI
|
|
|
18
23
|
(claude · aider · codex · goose · openhands)
|
|
19
24
|
도구 생략 시 지원 목록·설치 여부 표시
|
|
20
25
|
pleum run [model] ["프롬프트"] 터미널 채팅(REPL) / 1회 실행 / 파이프
|
|
21
|
-
모델 생략 시
|
|
26
|
+
모델 생략 시 화살표로 선택, /help 로 명령
|
|
22
27
|
pleum models [query] [--json] 모델 목록(가격·컨텍스트 포함)
|
|
28
|
+
pleum models <id> --info 모델 상세(가격·성능·provider별 uptime)
|
|
29
|
+
pleum top [--period weekly] 모델 리더보드·제공자 점유율(daily|weekly|monthly)
|
|
23
30
|
pleum balance 크레딧 잔액 조회
|
|
31
|
+
pleum usage [--by model] [--days 30] 내 지출 리포트(model|provider|api_key|day)
|
|
32
|
+
pleum health 게이트웨이 연결·로그인 상태 점검
|
|
24
33
|
pleum completion <zsh|bash> 셸 자동완성 스크립트 출력
|
|
25
34
|
pleum upgrade 최신 버전으로 재설치
|
|
26
35
|
pleum version 버전 출력
|
|
@@ -31,6 +40,8 @@ const HELP = `pleum — PleumRouter 터미널 CLI
|
|
|
31
40
|
pleum run gpt-4.1 # 대화형
|
|
32
41
|
pleum run gpt-4.1 "한 줄로 요약: ..." # 1회 실행
|
|
33
42
|
cat error.log | pleum run gpt-4.1 "원인은?" # 파이프
|
|
43
|
+
pleum models deepseek/deepseek-chat --info # 모델 상세
|
|
44
|
+
pleum usage --by day --days 7 # 최근 1주 일별 지출
|
|
34
45
|
pleum login --device # SSH/CI 환경
|
|
35
46
|
|
|
36
47
|
base_url 덮어쓰기: PLEUM_BASE_URL 환경변수`;
|
|
@@ -84,6 +95,15 @@ try {
|
|
|
84
95
|
case "balance":
|
|
85
96
|
await balance();
|
|
86
97
|
break;
|
|
98
|
+
case "usage":
|
|
99
|
+
await usage(rest);
|
|
100
|
+
break;
|
|
101
|
+
case "top":
|
|
102
|
+
await top(rest);
|
|
103
|
+
break;
|
|
104
|
+
case "health":
|
|
105
|
+
await health();
|
|
106
|
+
break;
|
|
87
107
|
case "completion":
|
|
88
108
|
completion(rest[0]);
|
|
89
109
|
break;
|
|
@@ -96,6 +116,9 @@ try {
|
|
|
96
116
|
version();
|
|
97
117
|
break;
|
|
98
118
|
case undefined:
|
|
119
|
+
// TTY면 ollama풍 런처 메뉴, 파이프/스크립트면 기존 도움말.
|
|
120
|
+
if (!(await mainMenu())) console.log(HELP);
|
|
121
|
+
break;
|
|
99
122
|
case "-h":
|
|
100
123
|
case "--help":
|
|
101
124
|
case "help":
|
package/package.json
CHANGED
package/src/ansi.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// 색상/스타일 프리미티브 — zero-dep. NO_COLOR·비TTY·dumb 터미널이면 자동 무색.
|
|
2
|
+
// 브랜드 컬러는 frontend globals.css의 --plum-bright(#936c89) 계열.
|
|
3
|
+
|
|
4
|
+
// 색상 지원 레벨: 0=무색, 1=16색, 2=256색, 3=truecolor. env/stream 주입 가능(테스트용).
|
|
5
|
+
export function detectColorLevel(env = process.env, stream = process.stdout) {
|
|
6
|
+
if (env.NO_COLOR != null) return 0;
|
|
7
|
+
if (env.FORCE_COLOR != null) {
|
|
8
|
+
const n = Number(env.FORCE_COLOR);
|
|
9
|
+
return Number.isFinite(n) ? Math.max(0, Math.min(3, n)) : 3;
|
|
10
|
+
}
|
|
11
|
+
if (!stream.isTTY || env.TERM === "dumb") return 0;
|
|
12
|
+
if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") return 3;
|
|
13
|
+
if (/-256(color)?$/.test(env.TERM || "")) return 2;
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PLUM = [147, 108, 137]; // #936c89 — 다크 터미널에서 가독성 있는 브랜드 보라
|
|
18
|
+
|
|
19
|
+
// RGB → xterm 256색 큐브 근사(16 + 36r + 6g + b, 각 축 0–5 양자화).
|
|
20
|
+
function rgbTo256([r, g, b]) {
|
|
21
|
+
const q = (v) => Math.round((v / 255) * 5);
|
|
22
|
+
return 16 + 36 * q(r) + 6 * q(g) + q(b);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 각 wrap은 \x1b[0m 완전 리셋 — 중첩 보존보다 색 누수 방지 우선(zero-dep 트레이드오프).
|
|
26
|
+
export function createStyler(level = detectColorLevel()) {
|
|
27
|
+
const wrap = (open) => (s) => (level ? `${open}${s}\x1b[0m` : String(s));
|
|
28
|
+
const rgb = (c, fallback16) =>
|
|
29
|
+
level >= 3
|
|
30
|
+
? wrap(`\x1b[38;2;${c.join(";")}m`)
|
|
31
|
+
: level === 2
|
|
32
|
+
? wrap(`\x1b[38;5;${rgbTo256(c)}m`)
|
|
33
|
+
: wrap(`\x1b[${fallback16}m`);
|
|
34
|
+
return {
|
|
35
|
+
level,
|
|
36
|
+
bold: wrap("\x1b[1m"),
|
|
37
|
+
dim: wrap("\x1b[2m"),
|
|
38
|
+
italic: wrap("\x1b[3m"),
|
|
39
|
+
plum: rgb(PLUM, 35), // 16색 폴백=마젠타
|
|
40
|
+
green: wrap("\x1b[32m"),
|
|
41
|
+
red: wrap("\x1b[31m"),
|
|
42
|
+
yellow: wrap("\x1b[33m"),
|
|
43
|
+
cyan: wrap("\x1b[36m"),
|
|
44
|
+
gray: wrap("\x1b[90m"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// CLI 런타임 싱글턴 — 파이프/NO_COLOR면 모든 함수가 항등이라 호출부 분기 불필요.
|
|
49
|
+
export const style = createStyler();
|
|
50
|
+
|
|
51
|
+
export function stripAnsi(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;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 터미널 표시 폭 — CJK(한글 등) 전각 문자는 2칸. 정렬·줄수 계산용.
|
|
64
|
+
export function displayWidth(s) {
|
|
65
|
+
let w = 0;
|
|
66
|
+
for (const ch of stripAnsi(s)) {
|
|
67
|
+
const c = ch.codePointAt(0);
|
|
68
|
+
const wide =
|
|
69
|
+
c >= 0x1100 &&
|
|
70
|
+
(c <= 0x115f || // 한글 자모
|
|
71
|
+
(c >= 0x2e80 && c <= 0xa4cf) || // CJK 부수·한자·가나
|
|
72
|
+
(c >= 0xac00 && c <= 0xd7a3) || // 한글 음절
|
|
73
|
+
(c >= 0xf900 && c <= 0xfaff) ||
|
|
74
|
+
(c >= 0xfe30 && c <= 0xfe4f) ||
|
|
75
|
+
(c >= 0xff00 && c <= 0xff60) || // 전각 영숫자
|
|
76
|
+
(c >= 0xffe0 && c <= 0xffe6) ||
|
|
77
|
+
(c >= 0x20000 && c <= 0x3fffd));
|
|
78
|
+
w += wide ? 2 : 1;
|
|
79
|
+
}
|
|
80
|
+
return w;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 표시 폭 기준 오른쪽 패딩 — ANSI·전각 섞여도 컬럼이 맞는다(String.padEnd 대체).
|
|
84
|
+
export function padDisplay(s, width) {
|
|
85
|
+
const pad = width - displayWidth(s);
|
|
86
|
+
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function terminalWidth(fallback = 80) {
|
|
90
|
+
return process.stdout.columns || fallback;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function divider(width = Math.min(terminalWidth(), 60), styler = style) {
|
|
94
|
+
return styler.gray("─".repeat(Math.max(1, width)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// "█████░░░░░" 수평 막대(순수 함수) — usage/top 공용.
|
|
98
|
+
export function barChart(value, max, width = 20) {
|
|
99
|
+
if (!Number.isFinite(value) || !max || max <= 0) return "░".repeat(width);
|
|
100
|
+
const filled = Math.round(Math.min(Math.max(value / max, 0), 1) * width);
|
|
101
|
+
return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
|
|
102
|
+
}
|
package/src/balance.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { V1 } from "./config.mjs";
|
|
2
|
-
import {
|
|
1
|
+
import { V1, requireKey } from "./config.mjs";
|
|
2
|
+
import { style } from "./ansi.mjs";
|
|
3
3
|
|
|
4
4
|
export async function balance() {
|
|
5
5
|
const key = requireKey();
|
|
@@ -8,11 +8,18 @@ export async function balance() {
|
|
|
8
8
|
});
|
|
9
9
|
if (!res.ok) {
|
|
10
10
|
const body = await res.text().catch(() => "");
|
|
11
|
-
console.error(`잔액 조회 실패 (HTTP ${res.status})${body ? `: ${body}` : ""}`);
|
|
11
|
+
console.error(`잔액 조회 실패 (HTTP ${res.status})${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
12
|
+
if (res.status === 401) {
|
|
13
|
+
console.error(style.dim("서버가 아직 API 키 잔액 조회를 지원하지 않거나 키가 만료됐을 수 있습니다 — pleum login 재시도."));
|
|
14
|
+
}
|
|
12
15
|
process.exit(1);
|
|
13
16
|
}
|
|
14
17
|
const data = await res.json();
|
|
15
18
|
const plm = data.balance_krw;
|
|
16
|
-
const usd = data.balance_usd != null ? ` / $${data.balance_usd.toFixed(4)}` : "";
|
|
17
|
-
console.log(`잔액: ${plm.toLocaleString("ko-KR")} PLM${usd}`);
|
|
19
|
+
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
|
+
const threshold = data.auto_charge_threshold_krw;
|
|
22
|
+
if (threshold != null && plm < threshold) {
|
|
23
|
+
console.log(style.yellow(`⚠ 잔액이 자동충전 임계값(${threshold.toLocaleString("ko-KR")} PLM) 아래입니다.`));
|
|
24
|
+
}
|
|
18
25
|
}
|
package/src/completion.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Shell 자동완성 스크립트 출력. 명령·도구는 정적, 모델명은 `pleum models`에서 동적으로 채운다.
|
|
2
2
|
// 설치: pleum completion zsh >> ~/.zshrc · pleum completion bash >> ~/.bashrc
|
|
3
|
-
const COMMANDS = "login logout launch run models balance version upgrade completion help";
|
|
3
|
+
const COMMANDS = "login logout launch run models top balance usage health version upgrade completion help";
|
|
4
4
|
const TOOLS = "claude aider codex goose openhands opencode crush";
|
|
5
5
|
|
|
6
6
|
function bash() {
|
package/src/health.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `pleum health` — 게이트웨이 연결성 + 로그인 상태 한눈에.
|
|
2
|
+
// 셀프호스트/스테이징(PLEUM_BASE_URL) 점검·고객지원 트리아지용. 공개 /v1/health 재사용.
|
|
3
|
+
|
|
4
|
+
import { ROOT, V1, readConfig } from "./config.mjs";
|
|
5
|
+
import { style, divider } from "./ansi.mjs";
|
|
6
|
+
|
|
7
|
+
const ok = (s) => `${style.green("●")} ${s}`;
|
|
8
|
+
const bad = (s) => `${style.red("●")} ${s}`;
|
|
9
|
+
const warn = (s) => `${style.yellow("●")} ${s}`;
|
|
10
|
+
|
|
11
|
+
// health 응답 + 로컬 상태 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
12
|
+
export function renderHealth({ root, health, error, ms, keyPrefix }, styler = style) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
lines.push(`${styler.bold(styler.plum("pleum health"))} ${styler.dim(`· ${root}`)}`);
|
|
15
|
+
lines.push(divider(undefined, styler));
|
|
16
|
+
if (error) {
|
|
17
|
+
lines.push(bad(`게이트웨이 연결 실패: ${error}`));
|
|
18
|
+
} else {
|
|
19
|
+
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}개 구성됨`));
|
|
24
|
+
const fx = health.fx_rate;
|
|
25
|
+
if (fx != null) {
|
|
26
|
+
const rate = typeof fx === "object" ? fx.rate : fx; // {rate, applied_rate, source}
|
|
27
|
+
const src = typeof fx === "object" && fx.source ? styler.dim(` (${fx.source})`) : "";
|
|
28
|
+
if (rate != null) lines.push(ok(`환율 USD/KRW ${rate}${src}`));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
lines.push(
|
|
32
|
+
keyPrefix
|
|
33
|
+
? ok(`로그인됨 ${styler.dim(`(키 ${keyPrefix}…)`)}`)
|
|
34
|
+
: warn(`로그인 안 됨 ${styler.dim("— pleum login")}`),
|
|
35
|
+
);
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function health() {
|
|
40
|
+
const cfg = readConfig();
|
|
41
|
+
const keyPrefix = cfg?.key ? cfg.key.slice(0, 8) : null;
|
|
42
|
+
const t0 = Date.now();
|
|
43
|
+
let data = null;
|
|
44
|
+
let error = null;
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`${V1}/health`);
|
|
47
|
+
if (!res.ok) error = `HTTP ${res.status}`;
|
|
48
|
+
else data = await res.json();
|
|
49
|
+
} catch (e) {
|
|
50
|
+
error = e.message;
|
|
51
|
+
}
|
|
52
|
+
const ms = Date.now() - t0;
|
|
53
|
+
console.log(renderHealth({ root: ROOT, health: data, error, ms, keyPrefix }));
|
|
54
|
+
if (error) process.exit(1);
|
|
55
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// 스피너 + raw-mode 화살표 셀렉터 — zero-dep 라인 플로우 인터랙션.
|
|
2
|
+
|
|
3
|
+
import { style, displayWidth, terminalWidth } from "./ansi.mjs";
|
|
4
|
+
|
|
5
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
|
+
|
|
7
|
+
// 브레일 스피너. 기본 stderr — stdout 파이프를 더럽히지 않는다. 비TTY면 no-op.
|
|
8
|
+
export function spinner(text, { stream = process.stderr, intervalMs = 80 } = {}) {
|
|
9
|
+
if (!stream.isTTY) return { update() {}, stop() {} };
|
|
10
|
+
let i = 0;
|
|
11
|
+
let msg = text;
|
|
12
|
+
let stopped = false;
|
|
13
|
+
stream.write("\x1b[?25l"); // 커서 숨김
|
|
14
|
+
const draw = () => stream.write(`\r\x1b[2K${style.plum(FRAMES[i++ % FRAMES.length])} ${style.dim(msg)}`);
|
|
15
|
+
draw();
|
|
16
|
+
const t = setInterval(draw, intervalMs);
|
|
17
|
+
return {
|
|
18
|
+
update(m) {
|
|
19
|
+
msg = m;
|
|
20
|
+
},
|
|
21
|
+
stop() {
|
|
22
|
+
if (stopped) return; // 멱등 — 중복 stop이 이후 출력 줄을 지우지 않게
|
|
23
|
+
stopped = true;
|
|
24
|
+
clearInterval(t);
|
|
25
|
+
stream.write("\r\x1b[2K\x1b[?25h");
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
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
|
+
// raw-mode 입력 청크 → 키 이벤트 배열(순수 함수 — 테스트 대상).
|
|
36
|
+
// 화살표(\x1b[A/B)와 일반 문자가 한 청크에 섞여 와도 순서대로 분해한다.
|
|
37
|
+
// 청크 끝의 외톨이 ESC는 escape(취소)로 처리 — 실터미널은 화살표 3바이트를
|
|
38
|
+
// 한 이벤트로 보내므로 실사용에서 오인될 일은 거의 없다.
|
|
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
|
+
export function parseKeys(chunk) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (let i = 0; i < chunk.length; ) {
|
|
44
|
+
const ch = chunk[i];
|
|
45
|
+
if (ch === "\x1b") {
|
|
46
|
+
if (chunk[i + 1] === "[") {
|
|
47
|
+
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
|
+
const m = /^([0-9;]*)([A-Za-z~])/.exec(rest);
|
|
64
|
+
if (m) {
|
|
65
|
+
const code = m[2];
|
|
66
|
+
if (code === "A") out.push({ type: "up" });
|
|
67
|
+
else if (code === "B") out.push({ type: "down" });
|
|
68
|
+
else if (code === "C") out.push({ type: "right" });
|
|
69
|
+
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는 무시
|
|
74
|
+
i += 2 + m[1].length + 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
out.push({ type: "escape" });
|
|
79
|
+
i += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === "\r" || ch === "\n") out.push({ type: "enter" });
|
|
83
|
+
else if (ch === "\x03") out.push({ type: "ctrlc" });
|
|
84
|
+
else if (ch === "\x7f" || ch === "\b") out.push({ type: "backspace" });
|
|
85
|
+
else if (ch >= " ") out.push({ type: "char", value: ch });
|
|
86
|
+
else out.push({ type: "unknown" });
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 청크 경계에서 잘린 CSI/SGR 시퀀스를 다음 청크와 이어 붙이는 스트림 래퍼(순수 폐쇄 — 테스트 대상).
|
|
93
|
+
// 1003 모션 홍수에서 \x1b[<… 가 중간에 잘리면 escape로 오인돼 UI가 닫히는 것을 방지.
|
|
94
|
+
// 외톨이 \x1b(=ESC 키)는 즉시 escape 처리(기존 트레이드오프 유지).
|
|
95
|
+
export function keyFeeder() {
|
|
96
|
+
let carry = "";
|
|
97
|
+
return (chunk) => {
|
|
98
|
+
let data = carry + chunk;
|
|
99
|
+
const cut = /\x1b\[[<0-9;]*$/.exec(data);
|
|
100
|
+
carry = cut ? cut[0] : "";
|
|
101
|
+
if (cut) data = data.slice(0, cut.index);
|
|
102
|
+
return parseKeys(data);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
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
|
+
// 화살표 이동 · 타이핑 필터 · Enter 선택 · Esc/Ctrl+C 취소(null).
|
|
137
|
+
// 마우스: 호버=하이라이트, 휠=이동, 좌클릭=선택.
|
|
138
|
+
// 비TTY면 즉시 null — 호출자가 기존 번호 피커 등으로 폴백한다.
|
|
139
|
+
// process.exit는 절대 호출하지 않는다(취소 처리는 호출자 몫).
|
|
140
|
+
export async function select(items, {
|
|
141
|
+
message = "선택",
|
|
142
|
+
formatItem = String,
|
|
143
|
+
matches = (all, q) => (q ? all.filter((it) => String(formatItem(it)).toLowerCase().includes(q.toLowerCase())) : all),
|
|
144
|
+
limit = 10,
|
|
145
|
+
input = process.stdin,
|
|
146
|
+
output = process.stdout,
|
|
147
|
+
} = {}) {
|
|
148
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return null;
|
|
149
|
+
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
let query = "";
|
|
152
|
+
let cursor = 0;
|
|
153
|
+
let lastRows = 0;
|
|
154
|
+
let lastLines = [];
|
|
155
|
+
let shownCount = 0;
|
|
156
|
+
let cprRow = 0; // 프레임 마지막 줄의 절대 행 — DSR \x1b[6n 응답으로 갱신
|
|
157
|
+
|
|
158
|
+
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
159
|
+
const erase = () => {
|
|
160
|
+
if (!lastRows) return;
|
|
161
|
+
output.write("\r" + (lastRows > 1 ? `\x1b[${lastRows - 1}A` : "") + "\x1b[0J");
|
|
162
|
+
lastRows = 0;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const render = () => {
|
|
166
|
+
const pool = matches(items, query);
|
|
167
|
+
if (cursor >= pool.length) cursor = Math.max(0, pool.length - 1);
|
|
168
|
+
const shown = pool.slice(0, limit);
|
|
169
|
+
const lines = [
|
|
170
|
+
`${style.plum("?")} ${style.bold(message)} ${style.dim("타이핑=필터 · ↑↓=이동 · Enter=선택 · Esc=취소")}`,
|
|
171
|
+
`${style.gray("›")} ${query}`,
|
|
172
|
+
...shown.map((it, i) =>
|
|
173
|
+
i === cursor ? `${style.plum("❯")} ${style.bold(formatItem(it))}` : ` ${style.dim(formatItem(it))}`,
|
|
174
|
+
),
|
|
175
|
+
];
|
|
176
|
+
if (pool.length > shown.length) lines.push(style.dim(` … ${pool.length - shown.length}개 더 — 타이핑으로 좁히기`));
|
|
177
|
+
if (!pool.length) lines.push(style.dim(" 일치 없음"));
|
|
178
|
+
erase();
|
|
179
|
+
const frame = lines.join("\n");
|
|
180
|
+
output.write(frame);
|
|
181
|
+
lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
|
|
182
|
+
lastLines = lines;
|
|
183
|
+
shownCount = shown.length;
|
|
184
|
+
output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
|
|
185
|
+
return pool;
|
|
186
|
+
};
|
|
187
|
+
|
|
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
|
+
const finish = (value) => {
|
|
195
|
+
input.off("data", onData);
|
|
196
|
+
input.setRawMode(false);
|
|
197
|
+
input.pause();
|
|
198
|
+
setMouse(output, false);
|
|
199
|
+
output.write("\x1b[?25h");
|
|
200
|
+
erase();
|
|
201
|
+
if (value != null) output.write(`${style.green("✓")} ${formatItem(value)}\n`);
|
|
202
|
+
resolve(value);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const feed = keyFeeder();
|
|
206
|
+
const onData = (chunk) => {
|
|
207
|
+
let dirty = false; // CPR만 온 턴에 재렌더하면 렌더→CPR 질의→응답 무한 루프
|
|
208
|
+
for (const key of feed(chunk)) {
|
|
209
|
+
if (key.type === "ctrlc" || key.type === "escape") return finish(null);
|
|
210
|
+
if (key.type === "cpr") {
|
|
211
|
+
cprRow = key.row;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (key.type === "enter") {
|
|
215
|
+
const pool = matches(items, query);
|
|
216
|
+
return finish(pool[cursor] ?? null);
|
|
217
|
+
}
|
|
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);
|
|
233
|
+
else if (key.type === "down") cursor = Math.min(limit - 1, cursor + 1);
|
|
234
|
+
else if (key.type === "backspace") query = query.slice(0, -1);
|
|
235
|
+
else if (key.type === "char") {
|
|
236
|
+
query += key.value;
|
|
237
|
+
cursor = 0;
|
|
238
|
+
} else continue;
|
|
239
|
+
dirty = true;
|
|
240
|
+
}
|
|
241
|
+
if (dirty) render();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
input.setEncoding("utf8"); // 멀티바이트(한글) 조각 자동 재조립
|
|
245
|
+
input.setRawMode(true);
|
|
246
|
+
input.resume();
|
|
247
|
+
output.write("\x1b[?25l");
|
|
248
|
+
setMouse(output, true);
|
|
249
|
+
input.on("data", onData);
|
|
250
|
+
render();
|
|
251
|
+
});
|
|
252
|
+
}
|
package/src/launch.mjs
CHANGED
|
@@ -5,8 +5,8 @@ import { join, delimiter } from "node:path";
|
|
|
5
5
|
import { requireKey } from "./config.mjs";
|
|
6
6
|
import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
|
|
7
7
|
|
|
8
|
-
// PATH에서 실행 파일을 찾는다(설치 여부 표시용).
|
|
9
|
-
function onPath(bin) {
|
|
8
|
+
// PATH에서 실행 파일을 찾는다(설치 여부 표시용). menu.mjs도 사용.
|
|
9
|
+
export function onPath(bin) {
|
|
10
10
|
const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
|
|
11
11
|
return (process.env.PATH || "")
|
|
12
12
|
.split(delimiter)
|
|
@@ -28,7 +28,8 @@ export function launchHelp() {
|
|
|
28
28
|
console.log("\n예: pleum launch claude --model claude-sonnet-4");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// exit:false면 process.exit 대신 자식 종료 코드를 반환 — 런처 메뉴의 실행 후 복귀용.
|
|
32
|
+
export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
32
33
|
const key = requireKey();
|
|
33
34
|
const r = RECIPES[tool];
|
|
34
35
|
|
|
@@ -59,20 +60,27 @@ export async function launch(tool, model, passthrough) {
|
|
|
59
60
|
if (r.note) console.log(`ℹ ${r.note}`);
|
|
60
61
|
console.log(`▶ ${r.label} 실행: ${r.bin} ${args.join(" ")}\n`);
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
return await new Promise((resolve) => {
|
|
64
|
+
const child = spawn(r.bin, args, {
|
|
65
|
+
env,
|
|
66
|
+
stdio: "inherit",
|
|
67
|
+
shell: process.platform === "win32",
|
|
68
|
+
});
|
|
69
|
+
child.on("error", (e) => {
|
|
70
|
+
if (e.code === "ENOENT") {
|
|
71
|
+
console.error(`✗ '${r.bin}'를 찾을 수 없습니다. 설치:\n ${r.install}`);
|
|
72
|
+
if (exit) process.exit(127);
|
|
73
|
+
return resolve(127);
|
|
74
|
+
}
|
|
75
|
+
console.error(`✗ ${e.message}`);
|
|
76
|
+
if (exit) process.exit(1);
|
|
77
|
+
resolve(1);
|
|
78
|
+
});
|
|
79
|
+
child.on("exit", (code) => {
|
|
80
|
+
if (exit) process.exit(code ?? 0);
|
|
81
|
+
resolve(code ?? 0);
|
|
82
|
+
});
|
|
66
83
|
});
|
|
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
84
|
}
|
|
77
85
|
|
|
78
86
|
function printManual(tool, key) {
|