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 +133 -0
- package/README.md +29 -4
- package/bin/pleum.mjs +36 -10
- package/package.json +13 -4
- package/src/completion.mjs +47 -0
- package/src/config.mjs +3 -0
- package/src/launch.mjs +25 -2
- package/src/models.mjs +66 -10
- package/src/run.mjs +323 -21
- package/src/upgrade.mjs +15 -0
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
|
|
19
|
-
| `pleum run
|
|
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
|
|
17
|
+
pleum launch [tool] [--model id] 코딩 에이전트를 PleumRouter로 실행
|
|
15
18
|
(claude · aider · codex · goose · openhands)
|
|
16
|
-
|
|
17
|
-
pleum
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
79
|
+
await run(rest);
|
|
65
80
|
break;
|
|
66
81
|
case "models":
|
|
67
|
-
await listModels(rest
|
|
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
|
|
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": "
|
|
21
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
21
22
|
"publishConfig": {
|
|
22
23
|
"access": "public"
|
|
23
24
|
},
|
|
24
|
-
"keywords": [
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
5
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
122
|
+
if (text === '"""') {
|
|
123
|
+
multiline = [];
|
|
24
124
|
rl.prompt();
|
|
25
125
|
return;
|
|
26
126
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/upgrade.mjs
ADDED
|
@@ -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
|
+
}
|