pleumrouter 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,133 @@
1
+ PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ Acceptance
6
+
7
+ In order to get any license under these terms, you must agree
8
+ to them as both strict obligations and conditions to all
9
+ your licenses.
10
+
11
+ Copyright License
12
+
13
+ The licensor grants you a copyright license for the
14
+ software to do everything you might do with the software
15
+ that would otherwise infringe the licensor's copyright
16
+ in it for any permitted purpose. However, you may only
17
+ distribute the software according to Distribution License
18
+ and make changes or new works based on the software
19
+ according to Changes and New Works License.
20
+
21
+ Distribution License
22
+
23
+ The licensor grants you an additional copyright license
24
+ to distribute copies of the software. Your license to
25
+ distribute covers distributing the software with changes
26
+ and new works permitted by Changes and New Works License.
27
+
28
+ Notices
29
+
30
+ You must ensure that anyone who gets a copy of any part of
31
+ the software from you also gets a copy of these terms or the
32
+ URL for them above, as well as copies of any plain-text lines
33
+ beginning with Required Notice: that the licensor provided
34
+ with the software. For example:
35
+
36
+ Required Notice: Copyright 2026 Lee Wonyoung (https://github.com/gachon-star-want/PleumRouter)
37
+
38
+ Changes and New Works License
39
+
40
+ The licensor grants you an additional copyright license to
41
+ make changes and new works based on the software for any
42
+ permitted purpose.
43
+
44
+ Patent License
45
+
46
+ The licensor grants you a patent license for the software
47
+ that covers patent claims the licensor can license, or will
48
+ be able to license, that you would infringe by using the
49
+ software.
50
+
51
+ Noncommercial Purposes
52
+
53
+ Any noncommercial purpose is a permitted purpose.
54
+
55
+ Personal Uses
56
+
57
+ Personal use for research, experiment, and testing for
58
+ the benefit of public knowledge, personal study, private
59
+ entertainment, hobby projects, amateur pursuits, or
60
+ religious observance, without any anticipated commercial
61
+ application, is use for a permitted purpose.
62
+
63
+ Noncommercial Organizations
64
+
65
+ Use by any charitable organization, educational institution,
66
+ public research organization, public safety or health
67
+ organization, environmental protection organization,
68
+ or government institution is use for a permitted purpose
69
+ regardless of the source of funding or obligations resulting
70
+ from the funding.
71
+
72
+ Fair Use
73
+
74
+ You may have "fair use" rights for the software under the
75
+ law. These terms do not limit them.
76
+
77
+ No Other Rights
78
+
79
+ These terms do not allow you to sublicense or transfer any of
80
+ your licenses to anyone else, or prevent the licensor from
81
+ granting licenses to anyone else. These terms do not imply
82
+ any other licenses.
83
+
84
+ Patent Defense
85
+
86
+ If you make any written claim that the software infringes or
87
+ contributes to infringement of any patent, your patent license
88
+ for the software granted under these terms ends immediately. If
89
+ your employer makes such a claim, your patent license ends
90
+ immediately for work on behalf of your employer.
91
+
92
+ Violations
93
+
94
+ The first time you are notified in writing that you have
95
+ violated any of these terms, or done anything with the software
96
+ not covered by your licenses, your licenses can nonetheless
97
+ continue if you come into compliance within 32 days after
98
+ receiving notice. Otherwise, all your licenses end permanently.
99
+
100
+ No Liability
101
+
102
+ As far as the law allows, the software comes as is, without
103
+ any warranty or condition, and the licensor will not be liable
104
+ to you for any damages arising out of these terms or the use
105
+ or nature of the software, under any kind of legal claim.
106
+
107
+ Definitions
108
+
109
+ The licensor is the individual or entity offering these
110
+ terms, and the software is the software the licensor makes
111
+ available under these terms.
112
+
113
+ You refers to the individual or entity agreeing to these
114
+ terms.
115
+
116
+ Your company is any legal entity, sole proprietorship,
117
+ or other kind of organization that you work for, plus all
118
+ organizations that have control over, are under the control of,
119
+ or are under common control with that organization. Control
120
+ means ownership of substantially all the assets of an entity,
121
+ or the power to direct its management and legal affairs.
122
+
123
+ Your licenses are all the licenses granted to you for the
124
+ software under these terms.
125
+
126
+ Use means anything you do with the software requiring one
127
+ of your licenses.
128
+
129
+ Trademark
130
+
131
+ The PolyForm Noncommercial License 1.0.0 does not grant
132
+ any rights to use the licensor's trademarks, trade names,
133
+ or logos.
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.1",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,16 +12,25 @@
12
12
  "files": [
13
13
  "bin",
14
14
  "src",
15
- "README.md"
15
+ "README.md",
16
+ "LICENSE"
16
17
  ],
17
18
  "scripts": {
18
19
  "test": "node --test"
19
20
  },
20
- "license": "MIT",
21
+ "license": "PolyForm-Noncommercial-1.0.0",
21
22
  "publishConfig": {
22
23
  "access": "public"
23
24
  },
24
- "keywords": ["pleum", "ai", "llm", "openai", "claude", "coding-agent", "cli"],
25
+ "keywords": [
26
+ "pleum",
27
+ "ai",
28
+ "llm",
29
+ "openai",
30
+ "claude",
31
+ "coding-agent",
32
+ "cli"
33
+ ],
25
34
  "homepage": "https://router.pleum.ai/docs/cli",
26
35
  "repository": {
27
36
  "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
+ }