pleumrouter 0.2.1 → 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
@@ -2,6 +2,20 @@
2
2
 
3
3
  PleumRouter 공식 터미널 CLI. 쓰던 코딩 에이전트를 한 줄로 PleumRouter에 붙여 실행한다.
4
4
 
5
+ ## 설치
6
+
7
+ Node.js 18 이상 필요(순수 Node CLI라 별도 바이너리 없음).
8
+
9
+ ```bash
10
+ curl -fsSL https://router.pleum.ai/install | sh # 원라이너(npm 전역 설치를 감쌈)
11
+ npm install -g pleumrouter # npm
12
+ bun install -g pleumrouter # bun
13
+ ```
14
+
15
+ 설치 없이 `npx pleumrouter <명령>` 으로 바로 실행할 수도 있다.
16
+
17
+ ## 빠른 시작
18
+
5
19
  ```bash
6
20
  npx pleumrouter login # 브라우저 로그인 + 키 자동 저장
7
21
  npx pleumrouter launch claude --model claude-sonnet-4
@@ -18,9 +32,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
18
32
  | `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
19
33
  | `pleum logout` | 저장된 키 삭제 |
20
34
  | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
21
- | `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 인터랙티브 선택 |
35
+ | `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택 |
22
36
  | `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
37
+ | `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
38
+ | `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
23
39
  | `pleum balance` | 크레딧 잔액 조회 |
40
+ | `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — 최신 로그인 키 필요(`usage:read`) |
41
+ | `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
24
42
  | `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
25
43
  | `pleum upgrade` | 최신 버전으로 재설치 |
26
44
  | `pleum version` | 버전 출력 |
@@ -32,7 +50,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
32
50
  | 명령 | 설명 |
33
51
  |------|------|
34
52
  | `/help`, `/?` | 명령 도움말 |
35
- | `/model [id]` | **세션 유지한 채 모델 전환**(여러 모델을 한 대화에서 비교) |
53
+ | `/model [id]` | **세션 유지한 채 모델 전환**(인자 없으면 화살표 셀렉터) |
36
54
  | `/system [텍스트]` | 시스템 프롬프트 설정/확인 |
37
55
  | `/clear` | 대화 컨텍스트 초기화 |
38
56
  | `/models [검색어]` | 모델 목록 보기 |
@@ -40,9 +58,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
40
58
  | `/verbose` | 토큰·속도 통계 토글 |
41
59
  | `/exit`, `/quit` | 종료 |
42
60
 
61
+ - 응답은 **마크다운 스트리밍 렌더**(굵게·코드블록 하이라이트·리스트) — 파이프로 빼면 자동으로 원문 그대로
62
+ - 응답 후 비용이 PLM으로 표시(스트리밍 마지막 청크 기준)
63
+ - **Ctrl+C**: 생성 중엔 1회로 중단(대화 유지), 대기 중엔 2초 내 2회로 종료
43
64
  - 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
44
65
  - 이미지: 메시지에 이미지 파일 경로를 그대로 포함 (예: `이 그림 설명해줘 ./shot.png`)
45
66
  - `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
67
+ - 색상 끄기: `NO_COLOR=1` (비TTY·파이프에선 자동 무색)
46
68
 
47
69
  ## 자동 실행 지원 도구
48
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.1",
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/config.mjs CHANGED
@@ -3,7 +3,9 @@ import { join } from "node:path";
3
3
  import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
4
4
 
5
5
  // 공개 게이트웨이 루트. 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
6
- export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://router.pleum.ai").replace(/\/+$/, "");
6
+ export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://router.pleum.ai")
7
+ .replace(/\/+$/, "")
8
+ .replace(/\/v1$/, ""); // 사용자가 실수로 /v1까지 붙여도 V1이 /v1/v1 이중 경로가 되지 않게 정리(L12)
7
9
  // OpenAI 호환 엔드포인트는 /v1. (Claude Code만 루트를 쓴다 — recipes 참고)
8
10
  export const V1 = `${ROOT}/v1`;
9
11
 
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
+ }
package/src/launch.mjs CHANGED
@@ -50,7 +50,7 @@ export async function launch(tool, model, passthrough) {
50
50
  // Codex는 격리 CODEX_HOME에 config.toml을 써서 사용자 ~/.codex를 보존한다.
51
51
  if (r.type === "codex-home") {
52
52
  const home = join(homedir(), ".pleum", "codex-home");
53
- mkdirSync(home, { recursive: true });
53
+ mkdirSync(home, { recursive: true, mode: 0o700 }); // 사용자 데이터 디렉토리 권한 제한(L11)
54
54
  writeFileSync(join(home, "config.toml"), codexToml(model), { mode: 0o600 });
55
55
  env.CODEX_HOME = home;
56
56
  }
@@ -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);
@@ -52,6 +65,11 @@ export async function run(rest) {
52
65
  const key = requireKey();
53
66
 
54
67
  if (!model && !oneShot) {
68
+ if (!process.stdin.isTTY) {
69
+ // 비대화형(non-TTY) stdin이면 인터랙티브 모델 선택이 멈춰버리므로 명확히 안내하고 종료(L10).
70
+ console.error('모델을 지정하세요: pleum run <model> ["프롬프트"] (비대화형 stdin에선 모델 선택 불가)');
71
+ process.exit(1);
72
+ }
55
73
  model = await pickModel(); // 인터랙티브 모델 선택
56
74
  if (!model) process.exit(1);
57
75
  }
@@ -62,11 +80,17 @@ export async function run(rest) {
62
80
 
63
81
  if (oneShot) {
64
82
  const prompt = [inlinePrompt, piped].filter(Boolean).join("\n\n");
65
- const messages = [{ role: "user", content: buildContent(prompt) }];
83
+ const content = buildContent(prompt);
84
+ if (Array.isArray(content)) {
85
+ const n = content.filter((p) => p.type === "image_url").length;
86
+ // 프롬프트 내 로컬 이미지 경로를 자동 첨부·업로드하므로 조용히 올리지 않고 명시 고지한다(M8).
87
+ console.error(`ℹ 프롬프트의 로컬 이미지 ${n}개를 첨부해 업로드합니다.`);
88
+ }
89
+ const messages = [{ role: "user", content }];
66
90
  try {
67
91
  await streamChat(key, model, messages, { verbose, quiet: true });
68
92
  } catch (e) {
69
- console.error(`✗ ${e.message}`);
93
+ console.error(`${style.red("✗")} ${e.message}`);
70
94
  process.exit(1);
71
95
  }
72
96
  process.exit(0);
@@ -75,34 +99,35 @@ export async function run(rest) {
75
99
  await repl(key, model, verbose);
76
100
  }
77
101
 
78
- async function repl(key, model, verbose) {
102
+ async function repl(key, initialModel, initialVerbose) {
103
+ let model = initialModel;
79
104
  let system = "";
105
+ let verbose = initialVerbose;
80
106
  let messages = [];
81
- const rl = createInterface({
82
- input: process.stdin,
83
- output: process.stdout,
84
- prompt: "you> ",
85
- history: loadHistory(),
86
- historySize: 1000,
87
- });
88
107
  let multiline = null; // """ 펜스 진행 중이면 누적 배열
89
-
90
- console.log(`PleumRouter · ${model}\n/help 으로 명령, /exit 로 종료. 여러 줄은 """ 로 감싸기.\n`);
91
- rl.prompt();
108
+ let currentAbort = null; // 생성 중이면 AbortController
109
+ let lastSigint = 0;
110
+ let pickerActive = false; // /model 셀렉터가 rl을 잠시 닫는 동안 close 핸들러 무시
111
+ let rl;
92
112
 
93
113
  const send = async (text) => {
94
114
  messages.push({ role: "user", content: buildContent(text) });
95
115
  const req = system ? [{ role: "system", content: system }, ...messages] : messages;
116
+ const ac = new AbortController();
117
+ currentAbort = ac;
96
118
  try {
97
- const reply = await streamChat(key, model, req, { verbose });
98
- 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 턴 되돌림
99
122
  } catch (e) {
100
- console.error(`\n✗ ${e.message}`);
123
+ console.error(`\n${style.red("✗")} ${e.message}`);
101
124
  messages.pop(); // 실패한 user 턴 제거
125
+ } finally {
126
+ currentAbort = null;
102
127
  }
103
128
  };
104
129
 
105
- rl.on("line", async (line) => {
130
+ const onLine = async (line) => {
106
131
  if (multiline !== null) {
107
132
  if (line.trim() === '"""') {
108
133
  const text = multiline.join("\n");
@@ -153,6 +178,17 @@ async function repl(key, model, verbose) {
153
178
  system = s.system ?? "";
154
179
  messages = s.messages ?? [];
155
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
+ },
156
192
  });
157
193
  rl.prompt();
158
194
  return;
@@ -163,20 +199,57 @@ async function repl(key, model, verbose) {
163
199
  }
164
200
  await send(text);
165
201
  rl.prompt();
166
- });
202
+ };
167
203
 
168
- rl.on("close", () => {
204
+ const onClose = () => {
205
+ if (pickerActive) return;
169
206
  saveHistory(rl.history);
170
- console.log("\n안녕히 가세요.");
207
+ console.log(`\n${style.dim("안녕히 가세요.")}`);
171
208
  process.exit(0);
172
- });
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();
173
246
  }
174
247
 
175
248
  const SLASH_HELP = `명령:
176
249
  /help, /? 이 도움말
177
250
  /clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
178
251
  /system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
179
- /model [id] 세션 유지한 채 모델 전환(인자 없으면 현재 모델)
252
+ /model [id] 세션 유지한 채 모델 전환(인자 없으면 화살표 선택)
180
253
  /models [검색어] 모델 목록 보기
181
254
  /save [이름] 현재 세션 저장(기본 last)
182
255
  /load [이름] 세션 불러오기
@@ -186,7 +259,7 @@ const SLASH_HELP = `명령:
186
259
  이미지: 메시지에 이미지 파일 경로를 그대로 포함(예: 이 그림 설명해줘 ./shot.png)`;
187
260
 
188
261
  async function handleSlash(text, ctx) {
189
- const [cmd, ...rest] = text.slice(1).split(/\s+/);
262
+ const [cmd] = text.slice(1).split(/\s+/);
190
263
  const arg = text.slice(1 + cmd.length).trim();
191
264
  switch (cmd) {
192
265
  case "help":
@@ -195,12 +268,12 @@ async function handleSlash(text, ctx) {
195
268
  break;
196
269
  case "clear":
197
270
  ctx.clear();
198
- console.log("✓ 컨텍스트 초기화");
271
+ console.log(`${style.green("✓")} 컨텍스트 초기화`);
199
272
  break;
200
273
  case "system":
201
274
  if (arg) {
202
275
  ctx.system = arg;
203
- console.log("✓ 시스템 프롬프트 설정");
276
+ console.log(`${style.green("✓")} 시스템 프롬프트 설정`);
204
277
  } else {
205
278
  console.log(ctx.system ? `현재 시스템 프롬프트:\n${ctx.system}` : "시스템 프롬프트 없음");
206
279
  }
@@ -208,7 +281,15 @@ async function handleSlash(text, ctx) {
208
281
  case "model":
209
282
  if (arg) {
210
283
  ctx.model = arg;
211
- 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
+ }
212
293
  } else {
213
294
  console.log(`현재 모델: ${ctx.model}\n전환: /model <id> · 목록: /models`);
214
295
  }
@@ -216,105 +297,147 @@ async function handleSlash(text, ctx) {
216
297
  case "models":
217
298
  try {
218
299
  const models = await fetchModels();
219
- const q = arg.toLowerCase();
220
- const rows = models.filter(
221
- (m) => !q || m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
222
- );
300
+ const rows = matchModels(models, arg);
223
301
  console.log(rows.length ? formatTable(rows) : "일치하는 모델 없음");
224
302
  } catch (e) {
225
- console.error(`✗ ${e.message}`);
303
+ console.error(`${style.red("✗")} ${e.message}`);
226
304
  }
227
305
  break;
228
306
  case "save":
229
307
  try {
230
308
  const name = saveSession(arg || "last", ctx.snapshot());
231
- console.log(`✓ 세션 저장: ${name}`);
309
+ console.log(`${style.green("✓")} 세션 저장: ${name}`);
232
310
  } catch (e) {
233
- console.error(`✗ 저장 실패: ${e.message}`);
311
+ console.error(`${style.red("✗")} 저장 실패: ${e.message}`);
234
312
  }
235
313
  break;
236
314
  case "load":
237
315
  try {
238
316
  ctx.restore(loadSession(arg || "last"));
239
- console.log(`✓ 세션 불러옴 (모델: ${ctx.model})`);
317
+ console.log(`${style.green("✓")} 세션 불러옴 (모델: ${ctx.model})`);
240
318
  } catch {
241
- console.error(`✗ 세션 없음: ${arg || "last"}`);
319
+ console.error(`${style.red("✗")} 세션 없음: ${arg || "last"}`);
242
320
  }
243
321
  break;
244
322
  case "verbose":
245
323
  ctx.verbose = !ctx.verbose;
246
- console.log(`✓ 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
324
+ console.log(`${style.green("✓")} 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
247
325
  break;
248
326
  default:
249
327
  console.log(`알 수 없는 명령: /${cmd} (/help)`);
250
328
  }
251
329
  }
252
330
 
253
- 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 } = {}) {
254
334
  const t0 = Date.now();
255
- const res = await fetch(`${V1}/chat/completions`, {
256
- method: "POST",
257
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
258
- body: JSON.stringify({ model, messages, stream: true, stream_options: { include_usage: true } }),
259
- });
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
+ }
260
352
  if (!res.ok) {
353
+ sp.stop();
261
354
  const body = await res.text().catch(() => "");
262
355
  throw new Error(`${res.status} ${body.slice(0, 300)}`);
263
356
  }
264
357
 
265
- if (!quiet) process.stdout.write("bot> ");
358
+ const md = createMarkdownStream({ prefix: quiet ? "" : `${style.plum("bot")}${style.dim(">")} ` });
266
359
  let full = "";
267
360
  let buf = "";
268
361
  let usage = null;
362
+ let cost = null;
269
363
  let firstAt = 0;
364
+ let aborted = false;
270
365
  const decoder = new TextDecoder();
271
- for await (const chunk of res.body) {
272
- buf += decoder.decode(chunk, { stream: true });
273
- const lines = buf.split("\n");
274
- buf = lines.pop() ?? "";
275
- for (const l of lines) {
276
- const s = l.trim();
277
- if (!s.startsWith("data:")) continue;
278
- const data = s.slice(5).trim();
279
- if (data === "[DONE]") continue;
280
- try {
281
- const j = JSON.parse(data);
282
- if (j.usage) usage = j.usage;
283
- const delta = j.choices?.[0]?.delta?.content;
284
- if (delta) {
285
- if (!firstAt) firstAt = Date.now();
286
- process.stdout.write(delta);
287
- 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
+ // 부분 청크 — 무시
288
391
  }
289
- } catch {
290
- // 부분 청크 — 무시
291
392
  }
292
393
  }
394
+ } catch (e) {
395
+ if (!isAbort(e)) {
396
+ sp.stop();
397
+ throw e;
398
+ }
399
+ aborted = true;
293
400
  }
294
- process.stdout.write("\n");
401
+ sp.stop();
402
+ md.finish();
403
+ if (aborted) console.error(style.dim("⏹ 생성 중단됨 — 부분 응답은 컨텍스트에 유지"));
295
404
 
296
405
  const ms = Date.now() - t0;
297
406
  const genSec = (Date.now() - (firstAt || t0)) / 1000;
298
407
  const tokPerSec = usage?.completion_tokens && genSec > 0 ? usage.completion_tokens / genSec : null;
299
- const cost = res.headers.get("x-cost-krw");
300
- // 파이프 출력(quiet)은 stdout을 깨끗이 — 통계는 verbose일 때만 stderr로.
301
- const line = formatStats({ cost: quiet ? null : cost, usage, ms, tokPerSec, verbose });
302
- if (line) (quiet ? console.error : console.log)(line);
303
- 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));
304
414
  return full;
305
415
  }
306
416
 
307
417
  // ── 인터랙티브 모델 피커 ──────────────────────────────────────────────
308
- // 번호 선택 + 검색어 좁히기. ponytail: 화살표 raw-mode는 후속(SSH/파이프 엣지케이스 회피).
418
+ // 화살표 셀렉터(raw-mode) 우선, 비TTY 등으로 불가하면 번호 선택 폴백.
309
419
  async function pickModel() {
310
420
  let models;
311
421
  try {
312
422
  models = await fetchModels();
313
423
  } catch (e) {
314
- console.error(`✗ 모델 목록 조회 실패: ${e.message}`);
424
+ console.error(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
315
425
  return null;
316
426
  }
317
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) {
318
441
  const rl = createInterface({ input: process.stdin, output: process.stdout });
319
442
  const ask = (q) => new Promise((r) => rl.question(q, r));
320
443
  let pool = models;
@@ -329,10 +452,7 @@ async function pickModel() {
329
452
  if (!ans) return null;
330
453
  const n = Number(ans);
331
454
  if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1].id;
332
- const q = ans.toLowerCase();
333
- const next = models.filter(
334
- (m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
335
- );
455
+ const next = matchModels(models, ans);
336
456
  pool = next.length ? next : models;
337
457
  if (!next.length) console.log("일치 없음 — 전체 목록으로.");
338
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
+ }