pleumrouter 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,9 +32,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
32
32
  | `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
33
33
  | `pleum logout` | 저장된 키 삭제 |
34
34
  | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
35
- | `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 인터랙티브 선택 |
35
+ | `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택 |
36
36
  | `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
37
+ | `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
38
+ | `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
37
39
  | `pleum balance` | 크레딧 잔액 조회 |
40
+ | `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — 최신 로그인 키 필요(`usage:read`) |
41
+ | `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
38
42
  | `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
39
43
  | `pleum upgrade` | 최신 버전으로 재설치 |
40
44
  | `pleum version` | 버전 출력 |
@@ -46,7 +50,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
46
50
  | 명령 | 설명 |
47
51
  |------|------|
48
52
  | `/help`, `/?` | 명령 도움말 |
49
- | `/model [id]` | **세션 유지한 채 모델 전환**(여러 모델을 한 대화에서 비교) |
53
+ | `/model [id]` | **세션 유지한 채 모델 전환**(인자 없으면 화살표 셀렉터) |
50
54
  | `/system [텍스트]` | 시스템 프롬프트 설정/확인 |
51
55
  | `/clear` | 대화 컨텍스트 초기화 |
52
56
  | `/models [검색어]` | 모델 목록 보기 |
@@ -54,9 +58,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
54
58
  | `/verbose` | 토큰·속도 통계 토글 |
55
59
  | `/exit`, `/quit` | 종료 |
56
60
 
61
+ - 응답은 **마크다운 스트리밍 렌더**(굵게·코드블록 하이라이트·리스트) — 파이프로 빼면 자동으로 원문 그대로
62
+ - 응답 후 비용이 PLM으로 표시(스트리밍 마지막 청크 기준)
63
+ - **Ctrl+C**: 생성 중엔 1회로 중단(대화 유지), 대기 중엔 2초 내 2회로 종료
57
64
  - 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
58
65
  - 이미지: 메시지에 이미지 파일 경로를 그대로 포함 (예: `이 그림 설명해줘 ./shot.png`)
59
66
  - `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
67
+ - 색상 끄기: `NO_COLOR=1` (비TTY·파이프에선 자동 무색)
60
68
 
61
69
  ## 자동 실행 지원 도구
62
70
 
package/bin/pleum.mjs CHANGED
@@ -5,6 +5,9 @@ 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";
@@ -18,9 +21,13 @@ const HELP = `pleum — PleumRouter 터미널 CLI
18
21
  (claude · aider · codex · goose · openhands)
19
22
  도구 생략 시 지원 목록·설치 여부 표시
20
23
  pleum run [model] ["프롬프트"] 터미널 채팅(REPL) / 1회 실행 / 파이프
21
- 모델 생략 시 인터랙티브 선택, /help 로 명령
24
+ 모델 생략 시 화살표로 선택, /help 로 명령
22
25
  pleum models [query] [--json] 모델 목록(가격·컨텍스트 포함)
26
+ pleum models <id> --info 모델 상세(가격·성능·provider별 uptime)
27
+ pleum top [--period weekly] 모델 리더보드·제공자 점유율(daily|weekly|monthly)
23
28
  pleum balance 크레딧 잔액 조회
29
+ pleum usage [--by model] [--days 30] 내 지출 리포트(model|provider|api_key|day)
30
+ pleum health 게이트웨이 연결·로그인 상태 점검
24
31
  pleum completion <zsh|bash> 셸 자동완성 스크립트 출력
25
32
  pleum upgrade 최신 버전으로 재설치
26
33
  pleum version 버전 출력
@@ -31,6 +38,8 @@ const HELP = `pleum — PleumRouter 터미널 CLI
31
38
  pleum run gpt-4.1 # 대화형
32
39
  pleum run gpt-4.1 "한 줄로 요약: ..." # 1회 실행
33
40
  cat error.log | pleum run gpt-4.1 "원인은?" # 파이프
41
+ pleum models deepseek/deepseek-chat --info # 모델 상세
42
+ pleum usage --by day --days 7 # 최근 1주 일별 지출
34
43
  pleum login --device # SSH/CI 환경
35
44
 
36
45
  base_url 덮어쓰기: PLEUM_BASE_URL 환경변수`;
@@ -84,6 +93,15 @@ try {
84
93
  case "balance":
85
94
  await balance();
86
95
  break;
96
+ case "usage":
97
+ await usage(rest);
98
+ break;
99
+ case "top":
100
+ await top(rest);
101
+ break;
102
+ case "health":
103
+ await health();
104
+ break;
87
105
  case "completion":
88
106
  completion(rest[0]);
89
107
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ansi.mjs ADDED
@@ -0,0 +1,95 @@
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
+ // eslint 불필요 — CSI 시퀀스만 제거하면 충분(우리가 쓰는 코드 전부 CSI).
53
+ return String(s).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
54
+ }
55
+
56
+ // 터미널 표시 폭 — CJK(한글 등) 전각 문자는 2칸. 정렬·줄수 계산용.
57
+ export function displayWidth(s) {
58
+ let w = 0;
59
+ for (const ch of stripAnsi(s)) {
60
+ const c = ch.codePointAt(0);
61
+ const wide =
62
+ c >= 0x1100 &&
63
+ (c <= 0x115f || // 한글 자모
64
+ (c >= 0x2e80 && c <= 0xa4cf) || // CJK 부수·한자·가나
65
+ (c >= 0xac00 && c <= 0xd7a3) || // 한글 음절
66
+ (c >= 0xf900 && c <= 0xfaff) ||
67
+ (c >= 0xfe30 && c <= 0xfe4f) ||
68
+ (c >= 0xff00 && c <= 0xff60) || // 전각 영숫자
69
+ (c >= 0xffe0 && c <= 0xffe6) ||
70
+ (c >= 0x20000 && c <= 0x3fffd));
71
+ w += wide ? 2 : 1;
72
+ }
73
+ return w;
74
+ }
75
+
76
+ // 표시 폭 기준 오른쪽 패딩 — ANSI·전각 섞여도 컬럼이 맞는다(String.padEnd 대체).
77
+ export function padDisplay(s, width) {
78
+ const pad = width - displayWidth(s);
79
+ return pad > 0 ? s + " ".repeat(pad) : s;
80
+ }
81
+
82
+ export function terminalWidth(fallback = 80) {
83
+ return process.stdout.columns || fallback;
84
+ }
85
+
86
+ export function divider(width = Math.min(terminalWidth(), 60), styler = style) {
87
+ return styler.gray("─".repeat(Math.max(1, width)));
88
+ }
89
+
90
+ // "█████░░░░░" 수평 막대(순수 함수) — usage/top 공용.
91
+ export function barChart(value, max, width = 20) {
92
+ if (!Number.isFinite(value) || !max || max <= 0) return "░".repeat(width);
93
+ const filled = Math.round(Math.min(Math.max(value / max, 0), 1) * width);
94
+ return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
95
+ }
package/src/balance.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { V1 } from "./config.mjs";
2
- import { requireKey } from "./config.mjs";
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
  }
@@ -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,145 @@
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
+ // raw-mode 입력 청크 → 키 이벤트 배열(순수 함수 — 테스트 대상).
31
+ // 화살표(\x1b[A/B)와 일반 문자가 한 청크에 섞여 와도 순서대로 분해한다.
32
+ // 청크 끝의 외톨이 ESC는 escape(취소)로 처리 — 실터미널은 화살표 3바이트를
33
+ // 한 이벤트로 보내므로 실사용에서 오인될 일은 거의 없다.
34
+ export function parseKeys(chunk) {
35
+ const out = [];
36
+ for (let i = 0; i < chunk.length; ) {
37
+ const ch = chunk[i];
38
+ if (ch === "\x1b") {
39
+ if (chunk[i + 1] === "[") {
40
+ const rest = chunk.slice(i + 2);
41
+ const m = /^([0-9;]*)([A-Za-z~])/.exec(rest);
42
+ if (m) {
43
+ const code = m[2];
44
+ if (code === "A") out.push({ type: "up" });
45
+ else if (code === "B") out.push({ type: "down" });
46
+ else out.push({ type: "unknown" }); // 좌우 화살표 등은 무시
47
+ i += 2 + m[1].length + 1;
48
+ continue;
49
+ }
50
+ }
51
+ out.push({ type: "escape" });
52
+ i += 1;
53
+ continue;
54
+ }
55
+ if (ch === "\r" || ch === "\n") out.push({ type: "enter" });
56
+ else if (ch === "\x03") out.push({ type: "ctrlc" });
57
+ else if (ch === "\x7f" || ch === "\b") out.push({ type: "backspace" });
58
+ else if (ch >= " ") out.push({ type: "char", value: ch });
59
+ else out.push({ type: "unknown" });
60
+ i += 1;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ // 화살표 이동 · 타이핑 필터 · Enter 선택 · Esc/Ctrl+C 취소(null).
66
+ // 비TTY면 즉시 null — 호출자가 기존 번호 피커 등으로 폴백한다.
67
+ // process.exit는 절대 호출하지 않는다(취소 처리는 호출자 몫).
68
+ export async function select(items, {
69
+ message = "선택",
70
+ formatItem = String,
71
+ matches = (all, q) => (q ? all.filter((it) => String(formatItem(it)).toLowerCase().includes(q.toLowerCase())) : all),
72
+ limit = 10,
73
+ input = process.stdin,
74
+ output = process.stdout,
75
+ } = {}) {
76
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return null;
77
+
78
+ return new Promise((resolve) => {
79
+ let query = "";
80
+ let cursor = 0;
81
+ let lastRows = 0;
82
+
83
+ const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
84
+ const erase = () => {
85
+ if (!lastRows) return;
86
+ output.write("\r" + (lastRows > 1 ? `\x1b[${lastRows - 1}A` : "") + "\x1b[0J");
87
+ lastRows = 0;
88
+ };
89
+
90
+ const render = () => {
91
+ const pool = matches(items, query);
92
+ if (cursor >= pool.length) cursor = Math.max(0, pool.length - 1);
93
+ const shown = pool.slice(0, limit);
94
+ const lines = [
95
+ `${style.plum("?")} ${style.bold(message)} ${style.dim("타이핑=필터 · ↑↓=이동 · Enter=선택 · Esc=취소")}`,
96
+ `${style.gray("›")} ${query}`,
97
+ ...shown.map((it, i) =>
98
+ i === cursor ? `${style.plum("❯")} ${style.bold(formatItem(it))}` : ` ${style.dim(formatItem(it))}`,
99
+ ),
100
+ ];
101
+ if (pool.length > shown.length) lines.push(style.dim(` … ${pool.length - shown.length}개 더 — 타이핑으로 좁히기`));
102
+ if (!pool.length) lines.push(style.dim(" 일치 없음"));
103
+ erase();
104
+ const frame = lines.join("\n");
105
+ output.write(frame);
106
+ lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
107
+ return pool;
108
+ };
109
+
110
+ const finish = (value) => {
111
+ input.off("data", onData);
112
+ input.setRawMode(false);
113
+ input.pause();
114
+ output.write("\x1b[?25h");
115
+ erase();
116
+ if (value != null) output.write(`${style.green("✓")} ${formatItem(value)}\n`);
117
+ resolve(value);
118
+ };
119
+
120
+ const onData = (chunk) => {
121
+ for (const key of parseKeys(chunk)) {
122
+ if (key.type === "ctrlc" || key.type === "escape") return finish(null);
123
+ if (key.type === "enter") {
124
+ const pool = matches(items, query);
125
+ return finish(pool[cursor] ?? null);
126
+ }
127
+ if (key.type === "up") cursor = Math.max(0, cursor - 1);
128
+ else if (key.type === "down") cursor = Math.min(limit - 1, cursor + 1);
129
+ else if (key.type === "backspace") query = query.slice(0, -1);
130
+ else if (key.type === "char") {
131
+ query += key.value;
132
+ cursor = 0;
133
+ }
134
+ }
135
+ render();
136
+ };
137
+
138
+ input.setEncoding("utf8"); // 멀티바이트(한글) 조각 자동 재조립
139
+ input.setRawMode(true);
140
+ input.resume();
141
+ output.write("\x1b[?25l");
142
+ input.on("data", onData);
143
+ render();
144
+ });
145
+ }
@@ -0,0 +1,91 @@
1
+ // 스트리밍 마크다운 렌더러 — 라인 플로우(풀스크린 아님).
2
+ // 전략: \n이 도착해 완결된 줄은 스타일 입혀 확정 출력(재렌더 없음), 아직 안 끝난
3
+ // 마지막 줄만 매 push마다 지우고 다시 그린다. 비TTY(파이프)는 완전 패스스루.
4
+
5
+ import { style, displayWidth, terminalWidth } from "./ansi.mjs";
6
+
7
+ // ```lang 펜스 감지(순수 함수). 열기/닫기 구분은 호출자의 inFence 상태가 한다.
8
+ export function detectFence(line) {
9
+ const m = /^\s*```(\S*)\s*$/.exec(line);
10
+ return m ? { lang: m[1] } : null;
11
+ }
12
+
13
+ // 인라인 마크다운: `code` → cyan, **bold**, *italic*. 코드 조각 먼저 치환해
14
+ // 코드 안의 별표가 bold로 오인되는 흔한 케이스를 줄인다.
15
+ export function renderInline(text, styler = style) {
16
+ return text
17
+ .replace(/`([^`]+)`/g, (_, c) => styler.cyan(c))
18
+ .replace(/\*\*([^*]+)\*\*/g, (_, c) => styler.bold(c))
19
+ .replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, (_, pre, c) => pre + styler.italic(c));
20
+ }
21
+
22
+ // 완결된 한 줄 렌더(순수 함수). state.inFence는 이 줄 "이전"의 펜스 상태.
23
+ export function renderLine(line, state = { inFence: false }, styler = style) {
24
+ const fence = detectFence(line);
25
+ if (fence) {
26
+ return state.inFence ? styler.gray("└─") : styler.gray("┌─ ") + styler.dim(fence.lang || "code");
27
+ }
28
+ if (state.inFence) return styler.gray("│ ") + line; // 펜스 안은 마크다운 해석 안 함
29
+ const h = /^(#{1,6})\s+(.*)$/.exec(line);
30
+ if (h) return styler.bold(styler.plum(h[2]));
31
+ const q = /^\s*>\s?(.*)$/.exec(line);
32
+ if (q) return styler.gray("┃ ") + styler.dim(renderInline(q[1], styler));
33
+ const li = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(line);
34
+ if (li) {
35
+ const marker = /^[-*+]$/.test(li[2]) ? "•" : li[2];
36
+ return `${li[1]}${styler.plum(marker)} ${renderInline(li[3], styler)}`;
37
+ }
38
+ return renderInline(line, styler);
39
+ }
40
+
41
+ // 스트리밍 렌더러 팩토리. write/styler/interactive 주입 가능(테스트용).
42
+ export function createMarkdownStream({
43
+ write = (s) => process.stdout.write(s),
44
+ styler = style,
45
+ interactive = process.stdout.isTTY,
46
+ prefix = "",
47
+ } = {}) {
48
+ let buf = "";
49
+ let inFence = false;
50
+ let firstLine = true; // prefix는 첫 줄에만
51
+ let previewRows = 0; // 직전 미완성 줄 렌더가 점유한 시각적 행 수(soft-wrap 포함)
52
+
53
+ const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
54
+
55
+ const erasePreview = () => {
56
+ if (!previewRows) return;
57
+ write("\r" + (previewRows > 1 ? `\x1b[${previewRows - 1}A` : "") + "\x1b[0J");
58
+ previewRows = 0;
59
+ };
60
+
61
+ const commit = (line) => {
62
+ const rendered = (firstLine ? prefix : "") + renderLine(line, { inFence }, styler);
63
+ if (detectFence(line)) inFence = !inFence;
64
+ write(rendered + "\n");
65
+ firstLine = false;
66
+ };
67
+
68
+ return {
69
+ push(delta) {
70
+ if (!interactive) return void write(delta);
71
+ buf += delta;
72
+ const lines = buf.split("\n");
73
+ buf = lines.pop() ?? "";
74
+ if (lines.length || buf) erasePreview();
75
+ for (const line of lines) commit(line);
76
+ if (buf) {
77
+ const rendered = (firstLine ? prefix : "") + renderLine(buf, { inFence }, styler);
78
+ write(rendered);
79
+ previewRows = rowsFor(rendered);
80
+ }
81
+ },
82
+ finish() {
83
+ if (!interactive) return void write("\n"); // 기존 파이프 동작 유지(항상 개행 종결)
84
+ erasePreview();
85
+ if (buf) {
86
+ commit(buf);
87
+ buf = "";
88
+ }
89
+ },
90
+ };
91
+ }
package/src/models.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { V1 } from "./config.mjs";
2
+ import { style, padDisplay, divider } from "./ansi.mjs";
2
3
 
3
4
  // GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
4
5
  export async function fetchModels() {
@@ -8,6 +9,15 @@ export async function fetchModels() {
8
9
  return data.data ?? [];
9
10
  }
10
11
 
12
+ // id/표시명 부분일치 필터 — run 피커·REPL /models·목록이 공유하는 단일 구현.
13
+ export function matchModels(models, query) {
14
+ const q = (query || "").toLowerCase();
15
+ if (!q) return models;
16
+ return models.filter(
17
+ (m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
18
+ );
19
+ }
20
+
11
21
  // 컨텍스트 길이를 사람이 읽기 좋게: 128000→128K, 1000000→1M.
12
22
  export function fmtCtx(n) {
13
23
  if (!n) return "—";
@@ -32,7 +42,8 @@ export function fmtPrice(model) {
32
42
  }
33
43
 
34
44
  // 정렬된 컬럼 테이블 — 라우터의 강점(수백 모델·가격·컨텍스트)을 한눈에.
35
- export function formatTable(models) {
45
+ // 패딩은 표시 폭 기준(padDisplay) — 색·전각 문자가 섞여도 컬럼이 맞는다.
46
+ export function formatTable(models, styler = style) {
36
47
  const headers = ["MODEL", "PROVIDER", "MODAL", "CTX", "IN/1M", "OUT/1M"];
37
48
  const rows = models.map((m) => {
38
49
  const [pin, pout] = fmtPrice(m);
@@ -46,29 +57,40 @@ export function formatTable(models) {
46
57
  ];
47
58
  });
48
59
  const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
49
- const fmt = (r) => r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
50
- return [fmt(headers), ...rows.map(fmt)].join("\n");
60
+ const fmtRow = (r, paint) =>
61
+ r.map((c, i) => paint(padDisplay(c, widths[i]), i)).join(" ").trimEnd();
62
+ const head = fmtRow(headers, (c) => styler.dim(c));
63
+ const body = rows.map((r) => fmtRow(r, (c, i) => (i === 0 ? styler.plum(c) : c)));
64
+ return [head, ...body].join("\n");
51
65
  }
52
66
 
53
- // `pleum models [query] [--json]` 인자 파싱.
67
+ // `pleum models [query] [--json] [--info]` 인자 파싱.
54
68
  export function parseModelsArgs(rest = []) {
55
69
  let json = false;
70
+ let info = false;
56
71
  const pos = [];
57
72
  for (const a of rest) {
58
73
  if (a === "--json") json = true;
74
+ else if (a === "--info" || a === "-i") info = true;
59
75
  else pos.push(a);
60
76
  }
61
- return { json, query: pos[0] };
77
+ return { json, info, query: pos[0] };
62
78
  }
63
79
 
64
80
  export async function listModels(rest) {
65
81
  // 하위호환: 문자열 하나가 와도 query로 처리.
66
- const { json, query } = Array.isArray(rest) ? parseModelsArgs(rest) : { json: false, query: rest };
82
+ const { json, info, query } = Array.isArray(rest)
83
+ ? parseModelsArgs(rest)
84
+ : { json: false, info: false, query: rest };
85
+ if (info) {
86
+ if (!query) {
87
+ console.error("사용법: pleum models <model-id> --info");
88
+ process.exit(1);
89
+ }
90
+ return showModelInfo(query, { json });
91
+ }
67
92
  const models = await fetchModels();
68
- const q = (query || "").toLowerCase();
69
- const rows = models.filter(
70
- (m) => !q || m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
71
- );
93
+ const rows = matchModels(models, query);
72
94
  if (json) {
73
95
  console.log(JSON.stringify(rows, null, 2));
74
96
  return;
@@ -78,5 +100,74 @@ export async function listModels(rest) {
78
100
  return;
79
101
  }
80
102
  console.log(formatTable(rows));
81
- console.log(`\n${rows.length} models · 가격=토큰 1M당(표시환율 기준, 실청구는 장중 환율로 소폭 상이)`);
103
+ console.log(style.dim(`\n${rows.length} models · 가격=토큰 1M당(표시환율 기준, 실청구는 장중 환율로 소폭 상이)`));
104
+ }
105
+
106
+ // ── 모델 상세(ollama show 대응) — GET /v1/models/{id} ────────────────────
107
+ // id는 provider/model 형식이라 슬래시를 그대로 둔다(백엔드 :path 컨버터).
108
+ export async function fetchModelDetail(id) {
109
+ const res = await fetch(`${V1}/models/${id}`);
110
+ if (res.status === 404) throw new Error(`모델을 찾을 수 없음: ${id} (목록: pleum models)`);
111
+ if (!res.ok) throw new Error(`모델 상세 조회 실패 (${res.status})`);
112
+ return res.json();
113
+ }
114
+
115
+ const fmtNum = (n) => (n == null ? "—" : Number(n).toLocaleString("en-US"));
116
+
117
+ // 상세 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
118
+ export function renderModelInfo(m, styler = style) {
119
+ const [pin, pout] = fmtPrice(m);
120
+ const lines = [];
121
+ lines.push(`${styler.bold(styler.plum(m.id))}${m.display_name ? ` ${styler.dim(m.display_name)}` : ""}`);
122
+ lines.push(divider(undefined, styler));
123
+ const facts = [
124
+ ["maker", m.maker || "—"],
125
+ ["modality", m.modality || "text"],
126
+ ["context", fmtCtx(m.context_length || m.context_window)],
127
+ ["max output", fmtCtx(m.max_output_tokens || m.max_output)],
128
+ ["가격/1M", `입력 ${pin} · 출력 ${pout} ${styler.dim(`(마크업 ${m.effective_markup_percent ?? 0}% 포함)`)}`],
129
+ ];
130
+ if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? "강제(끌 수 없음)" : "지원"]);
131
+ for (const [k, v] of facts) lines.push(`${styler.dim(padDisplay(k, 10))} ${v}`);
132
+
133
+ const u = m.usage;
134
+ if (u) {
135
+ lines.push(
136
+ `${styler.dim(padDisplay("사용량", 10))} 주간 ${fmtNum(u.weekly_calls)} calls · ${fmtNum(u.weekly_tokens)} tok` +
137
+ styler.dim(` (누적 ${fmtNum(u.total_calls)} calls)`),
138
+ );
139
+ }
140
+
141
+ const provs = m.providers || [];
142
+ if (provs.length) {
143
+ lines.push("");
144
+ lines.push(styler.dim("PROVIDER IN/1M OUT/1M CTX LATENCY TPS UPTIME"));
145
+ for (const p of provs) {
146
+ const [i1m, o1m] = fmtPrice(p);
147
+ lines.push(
148
+ [
149
+ styler.plum(padDisplay(p.provider, 12)),
150
+ padDisplay(i1m, 10),
151
+ padDisplay(o1m, 10),
152
+ padDisplay(fmtCtx(p.context_window), 5),
153
+ padDisplay(p.latency_ms != null ? `${Math.round(p.latency_ms)}ms` : "—", 8),
154
+ padDisplay(p.throughput_tps != null ? `${Math.round(p.throughput_tps)}/s` : "—", 6),
155
+ p.uptime_percent != null ? `${p.uptime_percent}%` : "—",
156
+ ].join(" "),
157
+ );
158
+ }
159
+ }
160
+
161
+ const sibs = (m.siblings || []).map((s) => s.id);
162
+ if (sibs.length) {
163
+ lines.push("");
164
+ lines.push(`${styler.dim("같은 제공자:")} ${sibs.join(styler.dim(" · "))}`);
165
+ }
166
+ return lines.join("\n");
167
+ }
168
+
169
+ export async function showModelInfo(id, { json = false } = {}) {
170
+ const detail = await fetchModelDetail(id);
171
+ if (json) return void console.log(JSON.stringify(detail, null, 2));
172
+ console.log(renderModelInfo(detail));
82
173
  }
package/src/run.mjs CHANGED
@@ -2,7 +2,10 @@ import { createInterface } from "node:readline";
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { V1, requireKey, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
5
- import { fetchModels, formatTable } from "./models.mjs";
5
+ import { fetchModels, formatTable, matchModels } from "./models.mjs";
6
+ import { style, divider } from "./ansi.mjs";
7
+ import { createMarkdownStream } from "./markdown.mjs";
8
+ import { spinner, select } from "./interactive.mjs";
6
9
 
7
10
  const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
8
11
 
@@ -34,7 +37,7 @@ export function buildContent(text, exists = existsSync, read = readFileSync) {
34
37
  // 응답 후 한 줄 통계(순수 함수 — 테스트 대상). verbose일 때만 토큰/속도 표시.
35
38
  export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
36
39
  const bits = [];
37
- if (cost) bits.push(`₩${cost}`);
40
+ if (cost != null && cost !== "") bits.push(`₩${fmtCost(cost)}`);
38
41
  if (verbose && usage) {
39
42
  bits.push(`↑${usage.prompt_tokens ?? "?"} ↓${usage.completion_tokens ?? "?"} tok`);
40
43
  if (tokPerSec) bits.push(`${tokPerSec.toFixed(1)} tok/s`);
@@ -43,6 +46,16 @@ export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
43
46
  return bits.length ? ` (${bits.join(" · ")})` : "";
44
47
  }
45
48
 
49
+ // 비용 표시: 숫자면 소수 2자리까지(후행 0 제거), 문자열은 그대로.
50
+ function fmtCost(v) {
51
+ const n = Number(v);
52
+ return Number.isFinite(n) ? String(+n.toFixed(2)) : String(v);
53
+ }
54
+
55
+ function isAbort(e) {
56
+ return e?.name === "AbortError" || e?.code === "ABORT_ERR";
57
+ }
58
+
46
59
  export async function run(rest) {
47
60
  const args = Array.isArray(rest) ? rest : rest == null ? [] : [rest];
48
61
  const { verbose, prompt: inlinePrompt, model: argModel } = parseRunArgs(args);
@@ -77,7 +90,7 @@ export async function run(rest) {
77
90
  try {
78
91
  await streamChat(key, model, messages, { verbose, quiet: true });
79
92
  } catch (e) {
80
- console.error(`✗ ${e.message}`);
93
+ console.error(`${style.red("✗")} ${e.message}`);
81
94
  process.exit(1);
82
95
  }
83
96
  process.exit(0);
@@ -86,34 +99,35 @@ export async function run(rest) {
86
99
  await repl(key, model, verbose);
87
100
  }
88
101
 
89
- async function repl(key, model, verbose) {
102
+ async function repl(key, initialModel, initialVerbose) {
103
+ let model = initialModel;
90
104
  let system = "";
105
+ let verbose = initialVerbose;
91
106
  let messages = [];
92
- const rl = createInterface({
93
- input: process.stdin,
94
- output: process.stdout,
95
- prompt: "you> ",
96
- history: loadHistory(),
97
- historySize: 1000,
98
- });
99
107
  let multiline = null; // """ 펜스 진행 중이면 누적 배열
100
-
101
- console.log(`PleumRouter · ${model}\n/help 으로 명령, /exit 로 종료. 여러 줄은 """ 로 감싸기.\n`);
102
- rl.prompt();
108
+ let currentAbort = null; // 생성 중이면 AbortController
109
+ let lastSigint = 0;
110
+ let pickerActive = false; // /model 셀렉터가 rl을 잠시 닫는 동안 close 핸들러 무시
111
+ let rl;
103
112
 
104
113
  const send = async (text) => {
105
114
  messages.push({ role: "user", content: buildContent(text) });
106
115
  const req = system ? [{ role: "system", content: system }, ...messages] : messages;
116
+ const ac = new AbortController();
117
+ currentAbort = ac;
107
118
  try {
108
- const reply = await streamChat(key, model, req, { verbose });
109
- messages.push({ role: "assistant", content: reply });
119
+ const reply = await streamChat(key, model, req, { verbose, signal: ac.signal });
120
+ if (reply) messages.push({ role: "assistant", content: reply });
121
+ else messages.pop(); // 응답 없음(즉시 중단 등) — user 턴 되돌림
110
122
  } catch (e) {
111
- console.error(`\n✗ ${e.message}`);
123
+ console.error(`\n${style.red("✗")} ${e.message}`);
112
124
  messages.pop(); // 실패한 user 턴 제거
125
+ } finally {
126
+ currentAbort = null;
113
127
  }
114
128
  };
115
129
 
116
- rl.on("line", async (line) => {
130
+ const onLine = async (line) => {
117
131
  if (multiline !== null) {
118
132
  if (line.trim() === '"""') {
119
133
  const text = multiline.join("\n");
@@ -164,6 +178,17 @@ async function repl(key, model, verbose) {
164
178
  system = s.system ?? "";
165
179
  messages = s.messages ?? [];
166
180
  },
181
+ // 화살표 셀렉터는 stdin raw-mode가 필요해 readline과 공존 불가 —
182
+ // 히스토리를 보존한 채 rl을 닫았다가 선택 후 재생성한다.
183
+ pickModel: async () => {
184
+ pickerActive = true;
185
+ const history = rl.history;
186
+ rl.close();
187
+ const picked = await pickModel();
188
+ makeRl(history);
189
+ pickerActive = false;
190
+ return picked;
191
+ },
167
192
  });
168
193
  rl.prompt();
169
194
  return;
@@ -174,20 +199,57 @@ async function repl(key, model, verbose) {
174
199
  }
175
200
  await send(text);
176
201
  rl.prompt();
177
- });
202
+ };
178
203
 
179
- rl.on("close", () => {
204
+ const onClose = () => {
205
+ if (pickerActive) return;
180
206
  saveHistory(rl.history);
181
- console.log("\n안녕히 가세요.");
207
+ console.log(`\n${style.dim("안녕히 가세요.")}`);
182
208
  process.exit(0);
183
- });
209
+ };
210
+
211
+ // Ctrl+C 인체공학: 생성 중 1회=중단(REPL 유지), idle에선 2초 내 2회=종료.
212
+ const onSigint = () => {
213
+ if (currentAbort) {
214
+ currentAbort.abort();
215
+ return;
216
+ }
217
+ const now = Date.now();
218
+ if (now - lastSigint < 2000) {
219
+ rl.close();
220
+ return;
221
+ }
222
+ lastSigint = now;
223
+ console.log(`\n${style.dim("한 번 더 Ctrl+C 하면 종료 (/exit 도 가능)")}`);
224
+ rl.prompt();
225
+ };
226
+
227
+ const makeRl = (history) => {
228
+ rl = createInterface({
229
+ input: process.stdin,
230
+ output: process.stdout,
231
+ prompt: `${style.plum("you")}${style.dim(">")} `,
232
+ history,
233
+ historySize: 1000,
234
+ });
235
+ rl.on("line", onLine);
236
+ rl.on("close", onClose);
237
+ rl.on("SIGINT", onSigint);
238
+ return rl;
239
+ };
240
+
241
+ makeRl(loadHistory());
242
+ console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
243
+ console.log(style.dim('/help 명령 · /exit 종료 · 여러 줄은 """ 감싸기 · 생성 중 Ctrl+C=중단'));
244
+ console.log(divider());
245
+ rl.prompt();
184
246
  }
185
247
 
186
248
  const SLASH_HELP = `명령:
187
249
  /help, /? 이 도움말
188
250
  /clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
189
251
  /system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
190
- /model [id] 세션 유지한 채 모델 전환(인자 없으면 현재 모델)
252
+ /model [id] 세션 유지한 채 모델 전환(인자 없으면 화살표 선택)
191
253
  /models [검색어] 모델 목록 보기
192
254
  /save [이름] 현재 세션 저장(기본 last)
193
255
  /load [이름] 세션 불러오기
@@ -197,7 +259,7 @@ const SLASH_HELP = `명령:
197
259
  이미지: 메시지에 이미지 파일 경로를 그대로 포함(예: 이 그림 설명해줘 ./shot.png)`;
198
260
 
199
261
  async function handleSlash(text, ctx) {
200
- const [cmd, ...rest] = text.slice(1).split(/\s+/);
262
+ const [cmd] = text.slice(1).split(/\s+/);
201
263
  const arg = text.slice(1 + cmd.length).trim();
202
264
  switch (cmd) {
203
265
  case "help":
@@ -206,12 +268,12 @@ async function handleSlash(text, ctx) {
206
268
  break;
207
269
  case "clear":
208
270
  ctx.clear();
209
- console.log("✓ 컨텍스트 초기화");
271
+ console.log(`${style.green("✓")} 컨텍스트 초기화`);
210
272
  break;
211
273
  case "system":
212
274
  if (arg) {
213
275
  ctx.system = arg;
214
- console.log("✓ 시스템 프롬프트 설정");
276
+ console.log(`${style.green("✓")} 시스템 프롬프트 설정`);
215
277
  } else {
216
278
  console.log(ctx.system ? `현재 시스템 프롬프트:\n${ctx.system}` : "시스템 프롬프트 없음");
217
279
  }
@@ -219,7 +281,15 @@ async function handleSlash(text, ctx) {
219
281
  case "model":
220
282
  if (arg) {
221
283
  ctx.model = arg;
222
- console.log(`✓ 모델 전환 → ${arg}`);
284
+ console.log(`${style.green("✓")} 모델 전환 → ${style.plum(arg)}`);
285
+ } else if (ctx.pickModel) {
286
+ const picked = await ctx.pickModel();
287
+ if (picked) {
288
+ ctx.model = picked;
289
+ console.log(`${style.green("✓")} 모델 전환 → ${style.plum(picked)}`);
290
+ } else {
291
+ console.log(`현재 모델: ${style.plum(ctx.model)}`);
292
+ }
223
293
  } else {
224
294
  console.log(`현재 모델: ${ctx.model}\n전환: /model <id> · 목록: /models`);
225
295
  }
@@ -227,105 +297,147 @@ async function handleSlash(text, ctx) {
227
297
  case "models":
228
298
  try {
229
299
  const models = await fetchModels();
230
- const q = arg.toLowerCase();
231
- const rows = models.filter(
232
- (m) => !q || m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
233
- );
300
+ const rows = matchModels(models, arg);
234
301
  console.log(rows.length ? formatTable(rows) : "일치하는 모델 없음");
235
302
  } catch (e) {
236
- console.error(`✗ ${e.message}`);
303
+ console.error(`${style.red("✗")} ${e.message}`);
237
304
  }
238
305
  break;
239
306
  case "save":
240
307
  try {
241
308
  const name = saveSession(arg || "last", ctx.snapshot());
242
- console.log(`✓ 세션 저장: ${name}`);
309
+ console.log(`${style.green("✓")} 세션 저장: ${name}`);
243
310
  } catch (e) {
244
- console.error(`✗ 저장 실패: ${e.message}`);
311
+ console.error(`${style.red("✗")} 저장 실패: ${e.message}`);
245
312
  }
246
313
  break;
247
314
  case "load":
248
315
  try {
249
316
  ctx.restore(loadSession(arg || "last"));
250
- console.log(`✓ 세션 불러옴 (모델: ${ctx.model})`);
317
+ console.log(`${style.green("✓")} 세션 불러옴 (모델: ${ctx.model})`);
251
318
  } catch {
252
- console.error(`✗ 세션 없음: ${arg || "last"}`);
319
+ console.error(`${style.red("✗")} 세션 없음: ${arg || "last"}`);
253
320
  }
254
321
  break;
255
322
  case "verbose":
256
323
  ctx.verbose = !ctx.verbose;
257
- console.log(`✓ 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
324
+ console.log(`${style.green("✓")} 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
258
325
  break;
259
326
  default:
260
327
  console.log(`알 수 없는 명령: /${cmd} (/help)`);
261
328
  }
262
329
  }
263
330
 
264
- async function streamChat(key, model, messages, { verbose = false, quiet = false } = {}) {
331
+ // SSE 스트리밍 채팅. fetchImpl 주입 가능(테스트용 — 네트워크 없이 SSE 파싱 검증).
332
+ // 비용은 마지막 SSE 청크의 cost.krw — 스트리밍 응답엔 X-Cost-Krw 헤더가 없다(백엔드 계약).
333
+ export async function streamChat(key, model, messages, { verbose = false, quiet = false, signal, fetchImpl = fetch } = {}) {
265
334
  const t0 = Date.now();
266
- const res = await fetch(`${V1}/chat/completions`, {
267
- method: "POST",
268
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
269
- body: JSON.stringify({ model, messages, stream: true, stream_options: { include_usage: true } }),
270
- });
335
+ const sp = spinner(`${model} 생각 중…`);
336
+ let res;
337
+ try {
338
+ res = await fetchImpl(`${V1}/chat/completions`, {
339
+ method: "POST",
340
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
341
+ body: JSON.stringify({ model, messages, stream: true, stream_options: { include_usage: true } }),
342
+ signal,
343
+ });
344
+ } catch (e) {
345
+ sp.stop();
346
+ if (isAbort(e)) {
347
+ console.error(style.dim("⏹ 중단됨"));
348
+ return "";
349
+ }
350
+ throw e;
351
+ }
271
352
  if (!res.ok) {
353
+ sp.stop();
272
354
  const body = await res.text().catch(() => "");
273
355
  throw new Error(`${res.status} ${body.slice(0, 300)}`);
274
356
  }
275
357
 
276
- if (!quiet) process.stdout.write("bot> ");
358
+ const md = createMarkdownStream({ prefix: quiet ? "" : `${style.plum("bot")}${style.dim(">")} ` });
277
359
  let full = "";
278
360
  let buf = "";
279
361
  let usage = null;
362
+ let cost = null;
280
363
  let firstAt = 0;
364
+ let aborted = false;
281
365
  const decoder = new TextDecoder();
282
- for await (const chunk of res.body) {
283
- buf += decoder.decode(chunk, { stream: true });
284
- const lines = buf.split("\n");
285
- buf = lines.pop() ?? "";
286
- for (const l of lines) {
287
- const s = l.trim();
288
- if (!s.startsWith("data:")) continue;
289
- const data = s.slice(5).trim();
290
- if (data === "[DONE]") continue;
291
- try {
292
- const j = JSON.parse(data);
293
- if (j.usage) usage = j.usage;
294
- const delta = j.choices?.[0]?.delta?.content;
295
- if (delta) {
296
- if (!firstAt) firstAt = Date.now();
297
- process.stdout.write(delta);
298
- full += delta;
366
+ try {
367
+ for await (const chunk of res.body) {
368
+ buf += decoder.decode(chunk, { stream: true });
369
+ const lines = buf.split("\n");
370
+ buf = lines.pop() ?? "";
371
+ for (const l of lines) {
372
+ const s = l.trim();
373
+ if (!s.startsWith("data:")) continue;
374
+ const data = s.slice(5).trim();
375
+ if (data === "[DONE]") continue;
376
+ try {
377
+ const j = JSON.parse(data);
378
+ if (j.usage) usage = j.usage;
379
+ if (j.cost) cost = j.cost; // 마지막 청크 — {usd, krw, fx_rate, markup_rate}
380
+ const delta = j.choices?.[0]?.delta?.content;
381
+ if (delta) {
382
+ if (!firstAt) {
383
+ firstAt = Date.now();
384
+ sp.stop();
385
+ }
386
+ md.push(delta);
387
+ full += delta;
388
+ }
389
+ } catch {
390
+ // 부분 청크 — 무시
299
391
  }
300
- } catch {
301
- // 부분 청크 — 무시
302
392
  }
303
393
  }
394
+ } catch (e) {
395
+ if (!isAbort(e)) {
396
+ sp.stop();
397
+ throw e;
398
+ }
399
+ aborted = true;
304
400
  }
305
- process.stdout.write("\n");
401
+ sp.stop();
402
+ md.finish();
403
+ if (aborted) console.error(style.dim("⏹ 생성 중단됨 — 부분 응답은 컨텍스트에 유지"));
306
404
 
307
405
  const ms = Date.now() - t0;
308
406
  const genSec = (Date.now() - (firstAt || t0)) / 1000;
309
407
  const tokPerSec = usage?.completion_tokens && genSec > 0 ? usage.completion_tokens / genSec : null;
310
- const cost = res.headers.get("x-cost-krw");
311
- // 파이프 출력(quiet)은 stdout을 깨끗이 — 통계는 verbose일 때만 stderr로.
312
- const line = formatStats({ cost: quiet ? null : cost, usage, ms, tokPerSec, verbose });
313
- if (line) (quiet ? console.error : console.log)(line);
314
- else if (quiet && verbose && cost) console.error(` (₩${cost})`);
408
+ const krw = cost?.krw ?? null;
409
+ // 파이프(quiet)는 stdout을 깨끗이 유지 — 통계는 stderr로. 비용은 터미널(stderr TTY)
410
+ // 또는 verbose일 때 표시하고, 완전 리다이렉트된 스크립트 실행에선 침묵한다.
411
+ const showCost = !quiet || verbose || process.stderr.isTTY;
412
+ const line = formatStats({ cost: showCost ? krw : null, usage, ms, tokPerSec, verbose });
413
+ if (line) (quiet ? console.error : console.log)(style.dim(line));
315
414
  return full;
316
415
  }
317
416
 
318
417
  // ── 인터랙티브 모델 피커 ──────────────────────────────────────────────
319
- // 번호 선택 + 검색어 좁히기. ponytail: 화살표 raw-mode는 후속(SSH/파이프 엣지케이스 회피).
418
+ // 화살표 셀렉터(raw-mode) 우선, 비TTY 등으로 불가하면 번호 선택 폴백.
320
419
  async function pickModel() {
321
420
  let models;
322
421
  try {
323
422
  models = await fetchModels();
324
423
  } catch (e) {
325
- console.error(`✗ 모델 목록 조회 실패: ${e.message}`);
424
+ console.error(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
326
425
  return null;
327
426
  }
328
427
  if (!models.length) return null;
428
+ if (process.stdin.isTTY && process.stdout.isTTY) {
429
+ return select(models, {
430
+ message: "모델 선택",
431
+ formatItem: (m) => `${m.id}${m.display_name ? ` ${style.dim(m.display_name)}` : ""}`,
432
+ matches: matchModels,
433
+ limit: 12,
434
+ }).then((m) => m?.id ?? null);
435
+ }
436
+ return pickModelNumbered(models);
437
+ }
438
+
439
+ // 번호 선택 + 검색어 좁히기 — raw-mode가 안 되는 환경 폴백.
440
+ async function pickModelNumbered(models) {
329
441
  const rl = createInterface({ input: process.stdin, output: process.stdout });
330
442
  const ask = (q) => new Promise((r) => rl.question(q, r));
331
443
  let pool = models;
@@ -340,10 +452,7 @@ async function pickModel() {
340
452
  if (!ans) return null;
341
453
  const n = Number(ans);
342
454
  if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1].id;
343
- const q = ans.toLowerCase();
344
- const next = models.filter(
345
- (m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
346
- );
455
+ const next = matchModels(models, ans);
347
456
  pool = next.length ? next : models;
348
457
  if (!next.length) console.log("일치 없음 — 전체 목록으로.");
349
458
  }
package/src/top.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // `pleum top [--period daily|weekly|monthly]` — 공개 리더보드(/v1/stats/rankings).
2
+ // ollama ps의 "지금 뜨는 것" 포지션 — 로컬 프로세스가 아니라 라우터 전체의 핫한 모델.
3
+
4
+ import { V1 } from "./config.mjs";
5
+ import { style, padDisplay, barChart, divider } from "./ansi.mjs";
6
+
7
+ export function parseTopArgs(rest = []) {
8
+ let period = "weekly";
9
+ for (let i = 0; i < rest.length; i++) {
10
+ if (rest[i] === "--period" || rest[i] === "-p") period = rest[++i];
11
+ else if (["daily", "weekly", "monthly"].includes(rest[i])) period = rest[i];
12
+ }
13
+ if (!["daily", "weekly", "monthly"].includes(period)) {
14
+ throw new Error(`period는 daily|weekly|monthly 중 하나: ${period}`);
15
+ }
16
+ return { period };
17
+ }
18
+
19
+ const fmtTok = (n) => {
20
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
21
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
22
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
23
+ return String(n);
24
+ };
25
+
26
+ // rankings 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
27
+ export function renderTop(data, styler = style) {
28
+ const periodKo = { daily: "일간", weekly: "주간", monthly: "월간" }[data.period] || data.period;
29
+ const lines = [];
30
+ lines.push(`${styler.bold(styler.plum("모델 리더보드"))} ${styler.dim(`· ${periodKo} · 토큰 ${fmtTok(data.total_tokens ?? 0)}`)}`);
31
+ lines.push(divider(undefined, styler));
32
+
33
+ const board = data.leaderboard || [];
34
+ if (!board.length) {
35
+ lines.push(styler.dim("데이터 없음"));
36
+ } else {
37
+ const idW = Math.max(...board.map((r) => r.model.length), 5);
38
+ for (const r of board) {
39
+ const trend =
40
+ r.trend_pct == null
41
+ ? styler.dim(" new")
42
+ : r.trend_pct >= 0
43
+ ? styler.green(`▲${r.trend_pct}%`)
44
+ : styler.red(`▼${Math.abs(r.trend_pct)}%`);
45
+ lines.push(
46
+ `${styler.dim(String(r.rank).padStart(2))}. ${styler.plum(padDisplay(r.model, idW))} ` +
47
+ `${padDisplay(fmtTok(r.tokens), 7)} tok ${padDisplay(String(r.calls), 6)} calls ${trend}` +
48
+ styler.dim(` by ${r.maker}`),
49
+ );
50
+ }
51
+ }
52
+
53
+ const share = data.market_share || [];
54
+ if (share.length) {
55
+ lines.push("");
56
+ lines.push(styler.bold("제공자 점유율"));
57
+ const nameW = Math.max(...share.map((s) => s.provider.length), 8);
58
+ for (const s of share) {
59
+ lines.push(
60
+ `${padDisplay(s.provider, nameW)} ${styler.plum(barChart(s.share_pct, 100, 24))} ${String(s.share_pct).padStart(5)}%`,
61
+ );
62
+ }
63
+ }
64
+ return lines.join("\n");
65
+ }
66
+
67
+ export async function top(rest) {
68
+ const { period } = parseTopArgs(rest);
69
+ const res = await fetch(`${V1}/stats/rankings?period=${period}`);
70
+ if (!res.ok) throw new Error(`리더보드 조회 실패 (${res.status})`);
71
+ console.log(renderTop(await res.json()));
72
+ }
package/src/usage.mjs ADDED
@@ -0,0 +1,69 @@
1
+ // `pleum usage [--by model|provider|api_key|day] [--days N]` — 내 지출 리포트.
2
+ // GET /v1/usage/report (API 키 + usage:read 스코프 — CLI 로그인 키에 기본 동봉).
3
+
4
+ import { V1, requireKey } from "./config.mjs";
5
+ import { style, padDisplay, barChart, divider } from "./ansi.mjs";
6
+
7
+ const GROUPS = ["model", "provider", "api_key", "day"];
8
+
9
+ export function parseUsageArgs(rest = []) {
10
+ let by = "model";
11
+ let days = 30;
12
+ for (let i = 0; i < rest.length; i++) {
13
+ if (rest[i] === "--by") by = rest[++i];
14
+ else if (rest[i] === "--days") days = Number(rest[++i]);
15
+ }
16
+ if (!GROUPS.includes(by)) throw new Error(`--by 는 ${GROUPS.join("|")} 중 하나: ${by}`);
17
+ if (!Number.isInteger(days) || days < 1 || days > 365) throw new Error(`--days 는 1~365 정수: ${days}`);
18
+ return { by, days };
19
+ }
20
+
21
+ const fmtKrw = (n) => `₩${Math.round(n).toLocaleString("en-US")}`;
22
+
23
+ // report 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
24
+ export function renderUsage(data, styler = style) {
25
+ const lines = [];
26
+ const byKo = { model: "모델별", provider: "제공자별", api_key: "키별", day: "일별" }[data.group_by] || data.group_by;
27
+ lines.push(`${styler.bold(styler.plum("사용량"))} ${styler.dim(`· ${byKo} · 최근 ${data.days}일`)}`);
28
+ lines.push(divider(undefined, styler));
29
+ const items = data.items || [];
30
+ if (!items.length) {
31
+ lines.push(styler.dim("사용 내역 없음"));
32
+ return lines.join("\n");
33
+ }
34
+ const max = Math.max(...items.map((i) => i.cost_krw), 1);
35
+ const keyW = Math.max(...items.map((i) => i.key.length), 4);
36
+ const total = { cost: 0, calls: 0 };
37
+ for (const it of items) {
38
+ total.cost += it.cost_krw;
39
+ total.calls += it.calls;
40
+ lines.push(
41
+ `${padDisplay(it.key, keyW)} ${styler.plum(barChart(it.cost_krw, max, 20))} ${padDisplay(fmtKrw(it.cost_krw), 10)}` +
42
+ styler.dim(` ↑${it.input_tokens.toLocaleString("en-US")} ↓${it.output_tokens.toLocaleString("en-US")} tok · ${it.calls} calls`),
43
+ );
44
+ }
45
+ lines.push(divider(undefined, styler));
46
+ lines.push(`${padDisplay("합계", keyW)} ${" ".repeat(20)} ${styler.bold(fmtKrw(total.cost))}${styler.dim(` ${total.calls} calls`)}`);
47
+ return lines.join("\n");
48
+ }
49
+
50
+ export async function usage(rest) {
51
+ const { by, days } = parseUsageArgs(rest);
52
+ const key = requireKey();
53
+ const res = await fetch(`${V1}/usage/report?group_by=${by}&days=${days}`, {
54
+ headers: { Authorization: `Bearer ${key}` },
55
+ });
56
+ if (res.status === 403) {
57
+ // 구버전 로그인 키(usage:read 스코프 없음) — 재로그인으로 새 키를 받으면 해결.
58
+ console.error(
59
+ `${style.red("✗")} 이 API 키에는 usage:read 권한이 없습니다.\n` +
60
+ ` ${style.dim("pleum login 을 다시 실행해 새 키를 발급받으세요 (최신 키에는 기본 포함).")}`,
61
+ );
62
+ process.exit(1);
63
+ }
64
+ if (!res.ok) {
65
+ const body = await res.text().catch(() => "");
66
+ throw new Error(`사용량 조회 실패 (${res.status})${body ? `: ${body.slice(0, 200)}` : ""}`);
67
+ }
68
+ console.log(renderUsage(await res.json()));
69
+ }