pleumrouter 0.1.0 → 0.2.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
@@ -7,17 +7,42 @@ npx pleumrouter login # 브라우저 로그인 + 키
7
7
  npx pleumrouter launch claude --model claude-sonnet-4
8
8
  npx pleumrouter launch codex --model gpt-4.1
9
9
  npx pleumrouter run gpt-4.1 # 터미널 채팅
10
+ npx pleumrouter run gpt-4.1 "한 줄 요약: ..." # 1회 실행(스크립트용)
11
+ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
10
12
  ```
11
13
 
12
14
  ## 명령
13
15
 
14
16
  | 명령 | 설명 |
15
17
  |------|------|
16
- | `pleum login` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`) |
18
+ | `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
17
19
  | `pleum logout` | 저장된 키 삭제 |
18
- | `pleum launch <tool> [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달 |
19
- | `pleum run <model>` | 스트리밍 채팅 REPL |
20
- | `pleum models [query]` | 모델 목록(공개) |
20
+ | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
21
+ | `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 인터랙티브 선택 |
22
+ | `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
23
+ | `pleum balance` | 크레딧 잔액 조회 |
24
+ | `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
25
+ | `pleum upgrade` | 최신 버전으로 재설치 |
26
+ | `pleum version` | 버전 출력 |
27
+
28
+ ## 채팅 REPL 명령
29
+
30
+ `pleum run` 안에서:
31
+
32
+ | 명령 | 설명 |
33
+ |------|------|
34
+ | `/help`, `/?` | 명령 도움말 |
35
+ | `/model [id]` | **세션 유지한 채 모델 전환**(여러 모델을 한 대화에서 비교) |
36
+ | `/system [텍스트]` | 시스템 프롬프트 설정/확인 |
37
+ | `/clear` | 대화 컨텍스트 초기화 |
38
+ | `/models [검색어]` | 모델 목록 보기 |
39
+ | `/save [이름]`, `/load [이름]` | 세션 저장·복원(`~/.pleum/sessions/`) |
40
+ | `/verbose` | 토큰·속도 통계 토글 |
41
+ | `/exit`, `/quit` | 종료 |
42
+
43
+ - 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
44
+ - 이미지: 메시지에 이미지 파일 경로를 그대로 포함 (예: `이 그림 설명해줘 ./shot.png`)
45
+ - `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
21
46
 
22
47
  ## 자동 실행 지원 도구
23
48
 
package/bin/pleum.mjs CHANGED
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
2
3
  import { login, loginDevice } from "../src/login.mjs";
3
- import { launch } from "../src/launch.mjs";
4
+ import { launch, launchHelp } from "../src/launch.mjs";
4
5
  import { run } from "../src/run.mjs";
5
6
  import { listModels } from "../src/models.mjs";
6
7
  import { balance } from "../src/balance.mjs";
8
+ import { completion } from "../src/completion.mjs";
9
+ import { upgrade } from "../src/upgrade.mjs";
7
10
  import { clearConfig } from "../src/config.mjs";
8
11
 
9
12
  const HELP = `pleum — PleumRouter 터미널 CLI
@@ -11,17 +14,24 @@ const HELP = `pleum — PleumRouter 터미널 CLI
11
14
  pleum login [--device] 브라우저로 로그인 + 키 자동 저장
12
15
  --device: 브라우저 없는 환경(SSH·CI)
13
16
  pleum logout 저장된 키 삭제
14
- pleum launch <tool> [--model id] 코딩 에이전트를 PleumRouter로 실행
17
+ pleum launch [tool] [--model id] 코딩 에이전트를 PleumRouter로 실행
15
18
  (claude · aider · codex · goose · openhands)
16
- pleum run <model> 터미널 채팅
17
- pleum models [query] 모델 목록
19
+ 도구 생략 지원 목록·설치 여부 표시
20
+ pleum run [model] ["프롬프트"] 터미널 채팅(REPL) / 1회 실행 / 파이프
21
+ 모델 생략 시 인터랙티브 선택, /help 로 명령
22
+ pleum models [query] [--json] 모델 목록(가격·컨텍스트 포함)
18
23
  pleum balance 크레딧 잔액 조회
24
+ pleum completion <zsh|bash> 셸 자동완성 스크립트 출력
25
+ pleum upgrade 최신 버전으로 재설치
26
+ pleum version 버전 출력
19
27
 
20
28
  예:
21
29
  pleum launch claude --model claude-sonnet-4
22
30
  pleum launch codex --model gpt-4.1 -- --full-auto
23
- pleum run gpt-4.1
24
- pleum login --device # SSH/CI 환경
31
+ pleum run gpt-4.1 # 대화형
32
+ pleum run gpt-4.1 "한 줄로 요약: ..." # 1회 실행
33
+ cat error.log | pleum run gpt-4.1 "원인은?" # 파이프
34
+ pleum login --device # SSH/CI 환경
25
35
 
26
36
  base_url 덮어쓰기: PLEUM_BASE_URL 환경변수`;
27
37
 
@@ -38,6 +48,11 @@ function parseLaunchArgs(args) {
38
48
  return out;
39
49
  }
40
50
 
51
+ function version() {
52
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
53
+ console.log(`pleum ${pkg.version}`);
54
+ }
55
+
41
56
  const [cmd, ...rest] = process.argv.slice(2);
42
57
 
43
58
  try {
@@ -53,22 +68,33 @@ try {
53
68
  case "launch": {
54
69
  const tool = rest[0];
55
70
  if (!tool) {
56
- console.error("사용법: pleum launch <tool> [--model id]");
57
- process.exit(1);
71
+ launchHelp();
72
+ break;
58
73
  }
59
74
  const { model, passthrough } = parseLaunchArgs(rest.slice(1));
60
75
  await launch(tool, model, passthrough);
61
76
  break;
62
77
  }
63
78
  case "run":
64
- await run(rest[0]);
79
+ await run(rest);
65
80
  break;
66
81
  case "models":
67
- await listModels(rest[0]);
82
+ await listModels(rest);
68
83
  break;
69
84
  case "balance":
70
85
  await balance();
71
86
  break;
87
+ case "completion":
88
+ completion(rest[0]);
89
+ break;
90
+ case "upgrade":
91
+ upgrade();
92
+ break;
93
+ case "version":
94
+ case "-v":
95
+ case "--version":
96
+ version();
97
+ break;
72
98
  case undefined:
73
99
  case "-h":
74
100
  case "--help":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,15 @@
21
21
  "publishConfig": {
22
22
  "access": "public"
23
23
  },
24
- "keywords": ["pleum", "ai", "llm", "openai", "claude", "coding-agent", "cli"],
24
+ "keywords": [
25
+ "pleum",
26
+ "ai",
27
+ "llm",
28
+ "openai",
29
+ "claude",
30
+ "coding-agent",
31
+ "cli"
32
+ ],
25
33
  "homepage": "https://router.pleum.ai/docs/cli",
26
34
  "repository": {
27
35
  "type": "git",
@@ -0,0 +1,47 @@
1
+ // Shell 자동완성 스크립트 출력. 명령·도구는 정적, 모델명은 `pleum models`에서 동적으로 채운다.
2
+ // 설치: pleum completion zsh >> ~/.zshrc · pleum completion bash >> ~/.bashrc
3
+ const COMMANDS = "login logout launch run models balance version upgrade completion help";
4
+ const TOOLS = "claude aider codex goose openhands opencode crush";
5
+
6
+ function bash() {
7
+ return `# pleum bash completion — eval "$(pleum completion bash)" 또는 ~/.bashrc에 추가
8
+ _pleum() {
9
+ local cur prev; cur="\${COMP_WORDS[COMP_CWORD]}"; prev="\${COMP_WORDS[COMP_CWORD-1]}"
10
+ if [ "$COMP_CWORD" -eq 1 ]; then
11
+ COMPREPLY=( $(compgen -W "${COMMANDS}" -- "$cur") ); return
12
+ fi
13
+ case "$prev" in
14
+ launch) COMPREPLY=( $(compgen -W "${TOOLS}" -- "$cur") );;
15
+ run) COMPREPLY=( $(compgen -W "$(pleum models 2>/dev/null | awk 'NR>1{print $1}')" -- "$cur") );;
16
+ completion) COMPREPLY=( $(compgen -W "zsh bash" -- "$cur") );;
17
+ esac
18
+ }
19
+ complete -F _pleum pleum`;
20
+ }
21
+
22
+ function zsh() {
23
+ return `#compdef pleum
24
+ # pleum zsh completion — eval "$(pleum completion zsh)" 또는 fpath에 _pleum로 추가
25
+ _pleum() {
26
+ if (( CURRENT == 2 )); then
27
+ compadd ${COMMANDS}; return
28
+ fi
29
+ case \${words[2]} in
30
+ launch) compadd ${TOOLS};;
31
+ run) compadd \${(f)"$(pleum models 2>/dev/null | awk 'NR>1{print \$1}')"};;
32
+ completion) compadd zsh bash;;
33
+ esac
34
+ }
35
+ _pleum "$@"`;
36
+ }
37
+
38
+ export function completion(shell) {
39
+ if (shell === "bash") return void console.log(bash());
40
+ if (shell === "zsh") return void console.log(zsh());
41
+ console.error(
42
+ "사용법: pleum completion <zsh|bash>\n" +
43
+ ' zsh: eval "$(pleum completion zsh)" (또는 ~/.zshrc에 추가)\n' +
44
+ ' bash: eval "$(pleum completion bash)" (또는 ~/.bashrc에 추가)',
45
+ );
46
+ process.exit(1);
47
+ }
package/src/config.mjs CHANGED
@@ -9,6 +9,9 @@ export const V1 = `${ROOT}/v1`;
9
9
 
10
10
  const DIR = join(homedir(), ".pleum");
11
11
  const FILE = join(DIR, "config.json");
12
+ // run REPL용 — 명령 히스토리 영속 + 세션 저장.
13
+ export const HISTORY_FILE = join(DIR, "history");
14
+ export const SESSIONS_DIR = join(DIR, "sessions");
12
15
 
13
16
  export function readConfig() {
14
17
  try {
package/src/launch.mjs CHANGED
@@ -1,10 +1,33 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, delimiter } from "node:path";
5
5
  import { requireKey } from "./config.mjs";
6
6
  import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
7
7
 
8
+ // PATH에서 실행 파일을 찾는다(설치 여부 표시용).
9
+ function onPath(bin) {
10
+ const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
11
+ return (process.env.PATH || "")
12
+ .split(delimiter)
13
+ .some((d) => d && exts.some((e) => existsSync(join(d, bin + e))));
14
+ }
15
+
16
+ // `pleum launch`(도구 미지정) — 지원 도구 + 설치 여부를 안내.
17
+ export function launchHelp() {
18
+ console.log("사용법: pleum launch <tool> [--model id] [-- 도구인자]\n");
19
+ console.log("자동 실행:");
20
+ for (const [name, r] of Object.entries(RECIPES)) {
21
+ const ok = onPath(r.bin) ? "✓ 설치됨" : `✗ 미설치 — ${r.install}`;
22
+ console.log(` ${name.padEnd(11)} ${r.label.padEnd(13)} ${ok}`);
23
+ }
24
+ console.log("\n설정 안내(provider 정의 파일 필요):");
25
+ for (const [name, m] of Object.entries(MANUAL)) {
26
+ console.log(` ${name.padEnd(11)} ${m.label}`);
27
+ }
28
+ console.log("\n예: pleum launch claude --model claude-sonnet-4");
29
+ }
30
+
8
31
  export async function launch(tool, model, passthrough) {
9
32
  const key = requireKey();
10
33
  const r = RECIPES[tool];
package/src/models.mjs CHANGED
@@ -8,19 +8,75 @@ export async function fetchModels() {
8
8
  return data.data ?? [];
9
9
  }
10
10
 
11
- export async function listModels(query) {
11
+ // 컨텍스트 길이를 사람이 읽기 좋게: 128000→128K, 1000000→1M.
12
+ export function fmtCtx(n) {
13
+ if (!n) return "—";
14
+ if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
15
+ if (n >= 1000) return `${Math.round(n / 1000)}K`;
16
+ return String(n);
17
+ }
18
+
19
+ // pricing(_krw)는 토큰당 단가 문자열. 1M 토큰당으로 환산해 표시(KRW 우선, 없으면 USD).
20
+ export function fmtPrice(model) {
21
+ const per1m = (v, sym) => {
22
+ if (v == null) return "—";
23
+ const n = Number(v) * 1_000_000;
24
+ if (!Number.isFinite(n)) return "—";
25
+ return sym === "₩" ? `₩${Math.round(n).toLocaleString("en-US")}` : `$${n.toFixed(2)}`;
26
+ };
27
+ const kr = model.pricing_krw || {};
28
+ if (kr.prompt != null || kr.completion != null) return [per1m(kr.prompt, "₩"), per1m(kr.completion, "₩")];
29
+ const us = model.pricing || {};
30
+ if (us.prompt != null || us.completion != null) return [per1m(us.prompt, "$"), per1m(us.completion, "$")];
31
+ return ["—", "—"];
32
+ }
33
+
34
+ // 정렬된 컬럼 테이블 — 라우터의 강점(수백 모델·가격·컨텍스트)을 한눈에.
35
+ export function formatTable(models) {
36
+ const headers = ["MODEL", "PROVIDER", "MODAL", "CTX", "IN/1M", "OUT/1M"];
37
+ const rows = models.map((m) => {
38
+ const [pin, pout] = fmtPrice(m);
39
+ return [
40
+ m.id,
41
+ m.provider || "",
42
+ m.modality || "text",
43
+ fmtCtx(m.context_length || m.context_window),
44
+ pin,
45
+ pout,
46
+ ];
47
+ });
48
+ 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");
51
+ }
52
+
53
+ // `pleum models [query] [--json]` 인자 파싱.
54
+ export function parseModelsArgs(rest = []) {
55
+ let json = false;
56
+ const pos = [];
57
+ for (const a of rest) {
58
+ if (a === "--json") json = true;
59
+ else pos.push(a);
60
+ }
61
+ return { json, query: pos[0] };
62
+ }
63
+
64
+ export async function listModels(rest) {
65
+ // 하위호환: 문자열 하나가 와도 query로 처리.
66
+ const { json, query } = Array.isArray(rest) ? parseModelsArgs(rest) : { json: false, query: rest };
12
67
  const models = await fetchModels();
13
68
  const q = (query || "").toLowerCase();
14
69
  const rows = models.filter(
15
- (m) =>
16
- !q ||
17
- m.id.toLowerCase().includes(q) ||
18
- (m.display_name || "").toLowerCase().includes(q),
70
+ (m) => !q || m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
19
71
  );
20
- for (const m of rows) {
21
- const parts = [m.id, m.modality || "text"];
22
- if (m.display_name) parts.push(m.display_name);
23
- console.log(parts.join("\t"));
72
+ if (json) {
73
+ console.log(JSON.stringify(rows, null, 2));
74
+ return;
75
+ }
76
+ if (!rows.length) {
77
+ console.log("일치하는 모델 없음");
78
+ return;
24
79
  }
25
- console.log(`\n${rows.length} models`);
80
+ console.log(formatTable(rows));
81
+ console.log(`\n${rows.length} models · 가격=토큰 1M당(표시환율 기준, 실청구는 장중 환율로 소폭 상이)`);
26
82
  }
package/src/run.mjs CHANGED
@@ -1,60 +1,272 @@
1
1
  import { createInterface } from "node:readline";
2
- import { V1, requireKey } from "./config.mjs";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { V1, requireKey, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
5
+ import { fetchModels, formatTable } from "./models.mjs";
3
6
 
4
- // 터미널 채팅 REPL — /v1/chat/completions 스트리밍을 토큰 단위로 출력.
5
- export async function run(model) {
7
+ const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
8
+
9
+ // `pleum run [model] ["프롬프트"...] [--verbose]` 인자 파싱(순수 함수 — 테스트 대상).
10
+ export function parseRunArgs(rest = []) {
11
+ let verbose = false;
12
+ const pos = [];
13
+ for (const a of rest) {
14
+ if (a === "--verbose" || a === "-v") verbose = true;
15
+ else pos.push(a);
16
+ }
17
+ return { model: pos[0], prompt: pos.slice(1).join(" "), verbose };
18
+ }
19
+
20
+ // 입력에서 존재하는 이미지 파일 경로를 찾아 멀티모달 content로 만든다(없으면 문자열 그대로).
21
+ // exists/read 주입 가능 — 테스트용.
22
+ export function buildContent(text, exists = existsSync, read = readFileSync) {
23
+ const imgs = text.split(/\s+/).filter((t) => IMG_EXT.test(t) && exists(t));
24
+ if (!imgs.length) return text;
25
+ const parts = [{ type: "text", text }];
26
+ for (const p of imgs) {
27
+ const ext = p.match(IMG_EXT)[1].toLowerCase().replace("jpg", "jpeg");
28
+ const b64 = Buffer.from(read(p)).toString("base64");
29
+ parts.push({ type: "image_url", image_url: { url: `data:image/${ext};base64,${b64}` } });
30
+ }
31
+ return parts;
32
+ }
33
+
34
+ // 응답 후 한 줄 통계(순수 함수 — 테스트 대상). verbose일 때만 토큰/속도 표시.
35
+ export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
36
+ const bits = [];
37
+ if (cost) bits.push(`₩${cost}`);
38
+ if (verbose && usage) {
39
+ bits.push(`↑${usage.prompt_tokens ?? "?"} ↓${usage.completion_tokens ?? "?"} tok`);
40
+ if (tokPerSec) bits.push(`${tokPerSec.toFixed(1)} tok/s`);
41
+ }
42
+ if (verbose) bits.push(`${ms}ms`);
43
+ return bits.length ? ` (${bits.join(" · ")})` : "";
44
+ }
45
+
46
+ export async function run(rest) {
47
+ const args = Array.isArray(rest) ? rest : rest == null ? [] : [rest];
48
+ const { verbose, prompt: inlinePrompt, model: argModel } = parseRunArgs(args);
49
+ let model = argModel;
50
+ const piped = await readStdin();
51
+ const oneShot = Boolean(inlinePrompt || piped);
52
+ const key = requireKey();
53
+
54
+ if (!model && !oneShot) {
55
+ model = await pickModel(); // 인터랙티브 모델 선택
56
+ if (!model) process.exit(1);
57
+ }
6
58
  if (!model) {
7
- console.error("사용법: pleum run <model>\n모델 목록: pleum models");
59
+ console.error('사용법: pleum run <model> ["프롬프트"]\n모델 목록: pleum models');
8
60
  process.exit(1);
9
61
  }
10
- const key = requireKey();
11
- const messages = [];
12
- const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
13
62
 
14
- console.log(`PleumRouter · ${model} · /exit 로 종료\n`);
63
+ if (oneShot) {
64
+ const prompt = [inlinePrompt, piped].filter(Boolean).join("\n\n");
65
+ const messages = [{ role: "user", content: buildContent(prompt) }];
66
+ try {
67
+ await streamChat(key, model, messages, { verbose, quiet: true });
68
+ } catch (e) {
69
+ console.error(`✗ ${e.message}`);
70
+ process.exit(1);
71
+ }
72
+ process.exit(0);
73
+ }
74
+
75
+ await repl(key, model, verbose);
76
+ }
77
+
78
+ async function repl(key, model, verbose) {
79
+ let system = "";
80
+ 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
+ let multiline = null; // """ 펜스 진행 중이면 누적 배열
89
+
90
+ console.log(`PleumRouter · ${model}\n/help 으로 명령, /exit 로 종료. 여러 줄은 """ 로 감싸기.\n`);
15
91
  rl.prompt();
16
92
 
93
+ const send = async (text) => {
94
+ messages.push({ role: "user", content: buildContent(text) });
95
+ const req = system ? [{ role: "system", content: system }, ...messages] : messages;
96
+ try {
97
+ const reply = await streamChat(key, model, req, { verbose });
98
+ messages.push({ role: "assistant", content: reply });
99
+ } catch (e) {
100
+ console.error(`\n✗ ${e.message}`);
101
+ messages.pop(); // 실패한 user 턴 제거
102
+ }
103
+ };
104
+
17
105
  rl.on("line", async (line) => {
106
+ if (multiline !== null) {
107
+ if (line.trim() === '"""') {
108
+ const text = multiline.join("\n");
109
+ multiline = null;
110
+ if (text.trim()) await send(text);
111
+ } else {
112
+ multiline.push(line);
113
+ }
114
+ rl.prompt();
115
+ return;
116
+ }
18
117
  const text = line.trim();
19
118
  if (text === "/exit" || text === "/quit") {
20
119
  rl.close();
21
120
  return;
22
121
  }
23
- if (!text) {
122
+ if (text === '"""') {
123
+ multiline = [];
24
124
  rl.prompt();
25
125
  return;
26
126
  }
27
- messages.push({ role: "user", content: text });
28
- try {
29
- const reply = await streamChat(key, model, messages);
30
- messages.push({ role: "assistant", content: reply });
31
- } catch (e) {
32
- console.error(`\n✗ ${e.message}`);
33
- messages.pop(); // 실패한 user 턴은 히스토리에서 제거
127
+ if (text.startsWith("/")) {
128
+ await handleSlash(text, {
129
+ get model() {
130
+ return model;
131
+ },
132
+ set model(m) {
133
+ model = m;
134
+ },
135
+ get system() {
136
+ return system;
137
+ },
138
+ set system(s) {
139
+ system = s;
140
+ },
141
+ get verbose() {
142
+ return verbose;
143
+ },
144
+ set verbose(v) {
145
+ verbose = v;
146
+ },
147
+ clear: () => {
148
+ messages = [];
149
+ },
150
+ snapshot: () => ({ model, system, messages }),
151
+ restore: (s) => {
152
+ model = s.model ?? model;
153
+ system = s.system ?? "";
154
+ messages = s.messages ?? [];
155
+ },
156
+ });
157
+ rl.prompt();
158
+ return;
159
+ }
160
+ if (!text) {
161
+ rl.prompt();
162
+ return;
34
163
  }
164
+ await send(text);
35
165
  rl.prompt();
36
166
  });
37
167
 
38
168
  rl.on("close", () => {
169
+ saveHistory(rl.history);
39
170
  console.log("\n안녕히 가세요.");
40
171
  process.exit(0);
41
172
  });
42
173
  }
43
174
 
44
- async function streamChat(key, model, messages) {
175
+ const SLASH_HELP = `명령:
176
+ /help, /? 이 도움말
177
+ /clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
178
+ /system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
179
+ /model [id] 세션 유지한 채 모델 전환(인자 없으면 현재 모델)
180
+ /models [검색어] 모델 목록 보기
181
+ /save [이름] 현재 세션 저장(기본 last)
182
+ /load [이름] 세션 불러오기
183
+ /verbose 토큰·속도 통계 표시 토글
184
+ /exit, /quit 종료
185
+ 여러 줄 입력: """ 한 줄 → 본문 → """ 한 줄
186
+ 이미지: 메시지에 이미지 파일 경로를 그대로 포함(예: 이 그림 설명해줘 ./shot.png)`;
187
+
188
+ async function handleSlash(text, ctx) {
189
+ const [cmd, ...rest] = text.slice(1).split(/\s+/);
190
+ const arg = text.slice(1 + cmd.length).trim();
191
+ switch (cmd) {
192
+ case "help":
193
+ case "?":
194
+ console.log(SLASH_HELP);
195
+ break;
196
+ case "clear":
197
+ ctx.clear();
198
+ console.log("✓ 컨텍스트 초기화");
199
+ break;
200
+ case "system":
201
+ if (arg) {
202
+ ctx.system = arg;
203
+ console.log("✓ 시스템 프롬프트 설정");
204
+ } else {
205
+ console.log(ctx.system ? `현재 시스템 프롬프트:\n${ctx.system}` : "시스템 프롬프트 없음");
206
+ }
207
+ break;
208
+ case "model":
209
+ if (arg) {
210
+ ctx.model = arg;
211
+ console.log(`✓ 모델 전환 → ${arg}`);
212
+ } else {
213
+ console.log(`현재 모델: ${ctx.model}\n전환: /model <id> · 목록: /models`);
214
+ }
215
+ break;
216
+ case "models":
217
+ try {
218
+ 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
+ );
223
+ console.log(rows.length ? formatTable(rows) : "일치하는 모델 없음");
224
+ } catch (e) {
225
+ console.error(`✗ ${e.message}`);
226
+ }
227
+ break;
228
+ case "save":
229
+ try {
230
+ const name = saveSession(arg || "last", ctx.snapshot());
231
+ console.log(`✓ 세션 저장: ${name}`);
232
+ } catch (e) {
233
+ console.error(`✗ 저장 실패: ${e.message}`);
234
+ }
235
+ break;
236
+ case "load":
237
+ try {
238
+ ctx.restore(loadSession(arg || "last"));
239
+ console.log(`✓ 세션 불러옴 (모델: ${ctx.model})`);
240
+ } catch {
241
+ console.error(`✗ 세션 없음: ${arg || "last"}`);
242
+ }
243
+ break;
244
+ case "verbose":
245
+ ctx.verbose = !ctx.verbose;
246
+ console.log(`✓ 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
247
+ break;
248
+ default:
249
+ console.log(`알 수 없는 명령: /${cmd} (/help)`);
250
+ }
251
+ }
252
+
253
+ async function streamChat(key, model, messages, { verbose = false, quiet = false } = {}) {
254
+ const t0 = Date.now();
45
255
  const res = await fetch(`${V1}/chat/completions`, {
46
256
  method: "POST",
47
257
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
48
- body: JSON.stringify({ model, messages, stream: true }),
258
+ body: JSON.stringify({ model, messages, stream: true, stream_options: { include_usage: true } }),
49
259
  });
50
260
  if (!res.ok) {
51
261
  const body = await res.text().catch(() => "");
52
262
  throw new Error(`${res.status} ${body.slice(0, 300)}`);
53
263
  }
54
264
 
55
- process.stdout.write("bot> ");
265
+ if (!quiet) process.stdout.write("bot> ");
56
266
  let full = "";
57
267
  let buf = "";
268
+ let usage = null;
269
+ let firstAt = 0;
58
270
  const decoder = new TextDecoder();
59
271
  for await (const chunk of res.body) {
60
272
  buf += decoder.decode(chunk, { stream: true });
@@ -66,8 +278,11 @@ async function streamChat(key, model, messages) {
66
278
  const data = s.slice(5).trim();
67
279
  if (data === "[DONE]") continue;
68
280
  try {
69
- const delta = JSON.parse(data).choices?.[0]?.delta?.content;
281
+ const j = JSON.parse(data);
282
+ if (j.usage) usage = j.usage;
283
+ const delta = j.choices?.[0]?.delta?.content;
70
284
  if (delta) {
285
+ if (!firstAt) firstAt = Date.now();
71
286
  process.stdout.write(delta);
72
287
  full += delta;
73
288
  }
@@ -77,7 +292,94 @@ async function streamChat(key, model, messages) {
77
292
  }
78
293
  }
79
294
  process.stdout.write("\n");
295
+
296
+ const ms = Date.now() - t0;
297
+ const genSec = (Date.now() - (firstAt || t0)) / 1000;
298
+ const tokPerSec = usage?.completion_tokens && genSec > 0 ? usage.completion_tokens / genSec : null;
80
299
  const cost = res.headers.get("x-cost-krw");
81
- if (cost) console.log(` (₩${cost})`);
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})`);
82
304
  return full;
83
305
  }
306
+
307
+ // ── 인터랙티브 모델 피커 ──────────────────────────────────────────────
308
+ // 번호 선택 + 검색어 좁히기. ponytail: 화살표 raw-mode는 후속(SSH/파이프 엣지케이스 회피).
309
+ async function pickModel() {
310
+ let models;
311
+ try {
312
+ models = await fetchModels();
313
+ } catch (e) {
314
+ console.error(`✗ 모델 목록 조회 실패: ${e.message}`);
315
+ return null;
316
+ }
317
+ if (!models.length) return null;
318
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
319
+ const ask = (q) => new Promise((r) => rl.question(q, r));
320
+ let pool = models;
321
+ try {
322
+ for (;;) {
323
+ const shown = pool.slice(0, 30);
324
+ shown.forEach((m, i) =>
325
+ console.log(` ${String(i + 1).padStart(2)}. ${m.id}${m.display_name ? ` (${m.display_name})` : ""}`),
326
+ );
327
+ if (pool.length > shown.length) console.log(` … ${pool.length - shown.length}개 더 — 검색어로 좁히기`);
328
+ const ans = (await ask("\n번호 선택 또는 검색어 (Enter=취소): ")).trim();
329
+ if (!ans) return null;
330
+ const n = Number(ans);
331
+ 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
+ );
336
+ pool = next.length ? next : models;
337
+ if (!next.length) console.log("일치 없음 — 전체 목록으로.");
338
+ }
339
+ } finally {
340
+ rl.close();
341
+ }
342
+ }
343
+
344
+ // ── 히스토리 / 세션 영속 ──────────────────────────────────────────────
345
+ function loadHistory() {
346
+ // readline history는 최신이 앞(역순). 파일은 시간순으로 저장한다.
347
+ try {
348
+ return readFileSync(HISTORY_FILE, "utf8").split("\n").filter(Boolean).reverse();
349
+ } catch {
350
+ return [];
351
+ }
352
+ }
353
+
354
+ function saveHistory(history) {
355
+ try {
356
+ mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 }); // ~/.pleum 보장(SESSIONS_DIR 부모)
357
+ const chrono = (history || []).slice(0, 1000).reverse().join("\n");
358
+ writeFileSync(HISTORY_FILE, chrono, { mode: 0o600 });
359
+ } catch {
360
+ // 히스토리 저장 실패는 치명적 아님 — 무시
361
+ }
362
+ }
363
+
364
+ function sessionPath(name) {
365
+ return join(SESSIONS_DIR, `${name.replace(/[^\w.-]/g, "_")}.json`);
366
+ }
367
+
368
+ function saveSession(name, data) {
369
+ mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
370
+ const path = sessionPath(name);
371
+ writeFileSync(path, JSON.stringify(data, null, 2), { mode: 0o600 });
372
+ return path;
373
+ }
374
+
375
+ function loadSession(name) {
376
+ return JSON.parse(readFileSync(sessionPath(name), "utf8"));
377
+ }
378
+
379
+ async function readStdin() {
380
+ if (process.stdin.isTTY) return "";
381
+ let data = "";
382
+ process.stdin.setEncoding("utf8");
383
+ for await (const c of process.stdin) data += c;
384
+ return data.trim();
385
+ }
@@ -0,0 +1,15 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ // 최신 버전으로 전역 재설치. brew tap 보류 상태라 npm 경로([[project_pleum_cli]]).
4
+ export function upgrade() {
5
+ console.log("▶ npm install -g pleumrouter@latest\n");
6
+ const child = spawn("npm", ["install", "-g", "pleumrouter@latest"], {
7
+ stdio: "inherit",
8
+ shell: process.platform === "win32",
9
+ });
10
+ child.on("error", (e) => {
11
+ console.error(`✗ ${e.message}`);
12
+ process.exit(1);
13
+ });
14
+ child.on("exit", (code) => process.exit(code ?? 0));
15
+ }