pleumrouter 0.3.1 → 0.4.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 +2 -1
- package/bin/pleum.mjs +48 -34
- package/package.json +1 -1
- package/src/balance.mjs +5 -4
- package/src/completion.mjs +8 -6
- package/src/config.mjs +2 -1
- package/src/health.mjs +9 -8
- package/src/i18n.mjs +37 -0
- package/src/interactive.mjs +11 -99
- package/src/lang.mjs +33 -0
- package/src/launch.mjs +17 -15
- package/src/locales/es.mjs +201 -0
- package/src/locales/ja.mjs +201 -0
- package/src/locales/ko.mjs +261 -0
- package/src/locales/zh.mjs +201 -0
- package/src/login.mjs +13 -12
- package/src/menu.mjs +113 -146
- package/src/models.mjs +12 -11
- package/src/recipes.mjs +81 -23
- package/src/run.mjs +41 -40
- package/src/top.mjs +7 -6
- package/src/usage.mjs +10 -9
package/src/menu.mjs
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
// Ollama 0.31 런처풍 시작 메뉴 — bare `pleum`(TTY)에서 뜬다. 비TTY면 false 반환(호출자가 HELP 폴백).
|
|
2
2
|
// 동작 패리티(ollama cmd/tui/tui.go·cmd/launch 소스 분석 기반):
|
|
3
3
|
// · 인라인 렌더(대체 화면 아님), ▸ 커서 + dim 설명 2줄 항목, 하단 키 안내
|
|
4
|
-
// · "더 보기…"는 커서가 닿는 순간 그 자리에서 펼쳐지고, pinned 구간으로 올라가면 접힘
|
|
5
4
|
// · →(configure) = 모델 피커를 강제로 띄우고 선택 즉시 실행(+ launch_models에 저장)
|
|
6
5
|
// · Enter = 저장된 모델(launch_models)로 바로 실행, 미설치면 Y/n 확인 후 자동 설치
|
|
7
6
|
// · 실행 종료 후 메뉴로 복귀(ollama runInteractiveTUI 루프), last_selection 커서 복원
|
|
8
|
-
// ·
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// · 인라인 렌더라 프레임 상단 행이 가변 — 렌더마다 DSR \x1b[6n으로 끝 행을 얻어 좌표 매핑
|
|
7
|
+
// · 키보드 전용: ↑/k ↓/j 이동 · enter/space 실행 · →/l 모델 설정 · esc/q 뒤로(최상위에선 종료) · ctrl+c 즉시 종료
|
|
8
|
+
// 에이전트는 최상위에 나열하지 않고 "에이전트 실행" 한 항목으로 묶어 서브메뉴(agentSelect)로 진입한다 —
|
|
9
|
+
// 지원 에이전트가 많아질수록 최상위 목록이 늘어지는 것을 막기 위함(runList는 두 메뉴가 공유하는 렌더 루프).
|
|
12
10
|
|
|
13
11
|
import { readFileSync } from "node:fs";
|
|
14
12
|
import { spawn } from "node:child_process";
|
|
15
13
|
import { createInterface } from "node:readline";
|
|
16
14
|
import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
|
|
17
|
-
import { keyFeeder, select
|
|
15
|
+
import { keyFeeder, select } from "./interactive.mjs";
|
|
18
16
|
import { readConfig, writeConfig } from "./config.mjs";
|
|
19
|
-
import { RECIPES } from "./recipes.mjs";
|
|
17
|
+
import { RECIPES, MANUAL } from "./recipes.mjs";
|
|
20
18
|
import { onPath, launch } from "./launch.mjs";
|
|
21
19
|
import { fetchModels, matchModels, listModels } from "./models.mjs";
|
|
22
20
|
import { run } from "./run.mjs";
|
|
@@ -25,51 +23,69 @@ import { top } from "./top.mjs";
|
|
|
25
23
|
import { usage } from "./usage.mjs";
|
|
26
24
|
import { balance } from "./balance.mjs";
|
|
27
25
|
import { health } from "./health.mjs";
|
|
26
|
+
import { t } from "./i18n.mjs";
|
|
28
27
|
|
|
29
|
-
const
|
|
30
|
-
claude: "Anthropic
|
|
31
|
-
codex: "OpenAI
|
|
32
|
-
aider: "
|
|
33
|
-
goose: "Block
|
|
34
|
-
openhands: "
|
|
28
|
+
const AGENT_DESC = {
|
|
29
|
+
claude: t("Anthropic's coding agent — supports subagents"),
|
|
30
|
+
codex: t("OpenAI's coding agent"),
|
|
31
|
+
aider: t("Terminal pair programming"),
|
|
32
|
+
goose: t("Block's local-first agent"),
|
|
33
|
+
openhands: t("Autonomous dev agent"),
|
|
34
|
+
opencode: t("Multi-provider terminal coding agent"),
|
|
35
|
+
omp: t("Bun-based coding agent — Claude routing only"),
|
|
36
|
+
hermes: t("Nous Research's general-purpose agent"),
|
|
37
|
+
qwen: t("Alibaba's terminal coding agent"),
|
|
38
|
+
copilot: t("GitHub's terminal coding agent"),
|
|
35
39
|
};
|
|
36
40
|
|
|
37
41
|
const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
|
|
38
42
|
|
|
39
|
-
// 메뉴 항목
|
|
40
|
-
export function buildItems({ loggedIn,
|
|
43
|
+
// 최상위 메뉴 항목(순수 함수 — 테스트 대상). 에이전트는 없음 — "agents" 한 항목으로 서브메뉴 진입.
|
|
44
|
+
export function buildItems({ loggedIn, savedModels = {} }) {
|
|
41
45
|
const items = [];
|
|
42
|
-
if (!loggedIn) items.push({ id: "login", title: "
|
|
46
|
+
if (!loggedIn) items.push({ id: "login", title: t("Log in"), desc: t("Log in via browser and save the API key") });
|
|
43
47
|
items.push({
|
|
44
48
|
id: "chat",
|
|
45
49
|
tool: "chat",
|
|
46
|
-
title: "
|
|
47
|
-
desc:
|
|
50
|
+
title: t("Chat with a model"),
|
|
51
|
+
desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
|
|
48
52
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
items.push({ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") });
|
|
54
|
+
items.push(
|
|
55
|
+
{ id: "models", title: t("Model list"), desc: t("All models — price and context included") },
|
|
56
|
+
{ id: "top", title: t("Leaderboard"), desc: t("Model rankings · provider share") },
|
|
57
|
+
{ id: "usage", title: t("Usage report"), desc: t("Spend over the last 30 days") },
|
|
58
|
+
{ id: "balance", title: t("Balance"), desc: t("Check your credit balance") },
|
|
59
|
+
{ id: "health", title: t("Health check"), desc: t("Gateway connectivity · login status") },
|
|
60
|
+
);
|
|
61
|
+
return items;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 에이전트 서브메뉴 항목(순수 함수 — 테스트 대상). RECIPES(자동 실행) + MANUAL(설정 안내만) 전부 포함.
|
|
65
|
+
export function buildAgentItems({ installed, savedModels = {} }) {
|
|
66
|
+
const items = [];
|
|
67
|
+
for (const [tool, r] of Object.entries(RECIPES)) {
|
|
52
68
|
items.push({
|
|
53
|
-
id: `launch:${
|
|
54
|
-
tool
|
|
55
|
-
title: `${r.label}
|
|
56
|
-
desc: installed[
|
|
57
|
-
? `${
|
|
69
|
+
id: `launch:${tool}`,
|
|
70
|
+
tool,
|
|
71
|
+
title: `${r.label}${installed[tool] ? "" : autoInstallable(r) ? ` (${t("install")})` : ` (${t("not installed")})`}`,
|
|
72
|
+
desc: installed[tool]
|
|
73
|
+
? `${AGENT_DESC[tool] ?? ""}${savedModels[tool] ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`
|
|
58
74
|
: autoInstallable(r)
|
|
59
|
-
?
|
|
60
|
-
:
|
|
75
|
+
? `${t("Press Enter to install now")} — ${r.install}`
|
|
76
|
+
: `${t("Manual install:")} ${hyperlink(r.install)}`,
|
|
61
77
|
});
|
|
62
78
|
}
|
|
63
|
-
|
|
64
|
-
items.push(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
for (const [tool, m] of Object.entries(MANUAL)) {
|
|
80
|
+
items.push({
|
|
81
|
+
id: `launch:${tool}`,
|
|
82
|
+
tool,
|
|
83
|
+
manual: true,
|
|
84
|
+
title: `${m.label} (${t("setup required")})`,
|
|
85
|
+
desc: installed[tool]
|
|
86
|
+
? t("Installed — press Enter for the {file} setup snippet", { file: m.file })
|
|
87
|
+
: `${t("✗ not installed —")} ${m.install}`,
|
|
88
|
+
});
|
|
73
89
|
}
|
|
74
90
|
return items;
|
|
75
91
|
}
|
|
@@ -93,11 +109,11 @@ async function pickModelFor(item) {
|
|
|
93
109
|
try {
|
|
94
110
|
models = await fetchModels();
|
|
95
111
|
} catch (e) {
|
|
96
|
-
console.log(`${style.red("✗")}
|
|
112
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
97
113
|
return undefined;
|
|
98
114
|
}
|
|
99
115
|
const m = await select(models, {
|
|
100
|
-
message: `${item.title
|
|
116
|
+
message: `${item.title} — ${t("select a model")}`,
|
|
101
117
|
formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
|
|
102
118
|
matches: matchModels,
|
|
103
119
|
limit: 12,
|
|
@@ -108,14 +124,14 @@ async function pickModelFor(item) {
|
|
|
108
124
|
// 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
|
|
109
125
|
async function ensureInstalled(r) {
|
|
110
126
|
if (!autoInstallable(r)) {
|
|
111
|
-
console.log(`${
|
|
127
|
+
console.log(`${t("{label} needs manual installation:", { label: r.label })}\n ${style.bold(hyperlink(r.install))}`);
|
|
112
128
|
return false;
|
|
113
129
|
}
|
|
114
130
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
115
|
-
const ans = await new Promise((res) => rl.question(
|
|
131
|
+
const ans = await new Promise((res) => rl.question(t("{label} isn't installed. Install it now? [Y/n] ", { label: r.label }), res));
|
|
116
132
|
rl.close();
|
|
117
133
|
if (/^n/i.test(ans.trim())) {
|
|
118
|
-
console.log(
|
|
134
|
+
console.log(t("Install canceled — manual install: {install}", { install: r.install }));
|
|
119
135
|
return false;
|
|
120
136
|
}
|
|
121
137
|
console.log(style.dim(`$ ${r.install}`));
|
|
@@ -125,59 +141,22 @@ async function ensureInstalled(r) {
|
|
|
125
141
|
c.on("error", () => res(1));
|
|
126
142
|
});
|
|
127
143
|
if (code !== 0) {
|
|
128
|
-
console.log(`${style.red("✗")}
|
|
144
|
+
console.log(`${style.red("✗")} ${t("Install failed (exit {code})", { code })}`);
|
|
129
145
|
return false;
|
|
130
146
|
}
|
|
131
|
-
console.log(`${style.green("✓")} ${r.label}
|
|
147
|
+
console.log(`${style.green("✓")} ${t("{label} installed", { label: r.label })}`);
|
|
132
148
|
return true;
|
|
133
149
|
}
|
|
134
150
|
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
const li = lineIndexAt(y, cprRow, lines, rowsFor);
|
|
139
|
-
if (li < 2) return -1;
|
|
140
|
-
const idx = (li - 2) >> 1;
|
|
141
|
-
return idx < itemCount ? idx : -1;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// 화살표+마우스 메뉴 루프 — {item, model?} 또는 null(Esc) 반환. 비TTY면 undefined.
|
|
145
|
-
async function menuSelect() {
|
|
151
|
+
// 화살표 메뉴 루프(키보드 전용) — {item, model?} · "quit"(ctrl+c) · null(esc/q) 반환. 비TTY면 undefined.
|
|
152
|
+
// 메인 메뉴·에이전트 서브메뉴가 공유하는 렌더 루프 — header/footer/초기 커서만 다르다.
|
|
153
|
+
async function runList(items, { header, footer, initialCursor = 0 } = {}) {
|
|
146
154
|
const input = process.stdin;
|
|
147
155
|
const output = process.stdout;
|
|
148
156
|
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
|
|
149
157
|
|
|
150
|
-
|
|
151
|
-
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
152
|
-
let expanded = false;
|
|
153
|
-
const rebuild = () => {
|
|
154
|
-
const cfg = readConfig();
|
|
155
|
-
return buildItems({ loggedIn: Boolean(cfg?.key), installed, savedModels: cfg?.launch_models ?? {}, expanded });
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
let items = rebuild();
|
|
159
|
-
const pinnedCount = items.length - 1; // "더 보기…" 자리 — 여기 닿으면 펼침, 위로 벗어나면 접힘
|
|
160
|
-
let cursor = 0;
|
|
161
|
-
|
|
162
|
-
// last_selection 커서 복원 — 더 보기 그룹에 있었으면 펼친 채 시작(ollama shouldExpandOthers).
|
|
163
|
-
const last = readConfig()?.last_selection;
|
|
164
|
-
if (last) {
|
|
165
|
-
let idx = items.findIndex((it) => it.id === last);
|
|
166
|
-
if (idx < 0) {
|
|
167
|
-
expanded = true;
|
|
168
|
-
items = rebuild();
|
|
169
|
-
idx = items.findIndex((it) => it.id === last);
|
|
170
|
-
if (idx < 0) {
|
|
171
|
-
expanded = false;
|
|
172
|
-
items = rebuild();
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (idx >= 0) cursor = idx;
|
|
176
|
-
}
|
|
177
|
-
|
|
158
|
+
let cursor = Math.max(0, Math.min(initialCursor, items.length - 1));
|
|
178
159
|
let lastRows = 0;
|
|
179
|
-
let lastLines = [];
|
|
180
|
-
let cprRow = 0;
|
|
181
160
|
|
|
182
161
|
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
183
162
|
const erase = () => {
|
|
@@ -187,7 +166,7 @@ async function menuSelect() {
|
|
|
187
166
|
};
|
|
188
167
|
const render = () => {
|
|
189
168
|
const lines = [
|
|
190
|
-
|
|
169
|
+
header,
|
|
191
170
|
"",
|
|
192
171
|
...items.flatMap((it, i) =>
|
|
193
172
|
i === cursor
|
|
@@ -195,38 +174,20 @@ async function menuSelect() {
|
|
|
195
174
|
: [` ${it.title}`, ` ${style.dim(it.desc)}`],
|
|
196
175
|
),
|
|
197
176
|
"",
|
|
198
|
-
style.dim(
|
|
177
|
+
style.dim(footer),
|
|
199
178
|
];
|
|
200
179
|
erase();
|
|
201
180
|
output.write(lines.join("\n"));
|
|
202
181
|
lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
|
|
203
|
-
lastLines = lines;
|
|
204
|
-
output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
|
|
205
182
|
};
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (!expanded && next === pinnedCount) {
|
|
209
|
-
expanded = true; // "더 보기…"에 커서가 닿으면 그 자리에서 펼침
|
|
210
|
-
items = rebuild();
|
|
211
|
-
cursor = next;
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (expanded && next < pinnedCount) {
|
|
215
|
-
expanded = false; // pinned 구간으로 돌아가면 접힘
|
|
216
|
-
items = rebuild();
|
|
217
|
-
cursor = next;
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
cursor = next;
|
|
183
|
+
const move = (dir) => {
|
|
184
|
+
cursor = Math.max(0, Math.min(items.length - 1, cursor + dir));
|
|
221
185
|
};
|
|
222
|
-
const move = (dir) => moveTo(Math.max(0, Math.min(items.length - 1, cursor + dir)));
|
|
223
|
-
const itemAt = (y) => itemIndexAt(y, cprRow, lastLines, items.length, rowsFor);
|
|
224
186
|
|
|
225
187
|
return new Promise((resolve) => {
|
|
226
188
|
const suspend = () => {
|
|
227
189
|
input.off("data", onData);
|
|
228
190
|
input.setRawMode(false);
|
|
229
|
-
setMouse(output, false);
|
|
230
191
|
output.write("\x1b[?25h");
|
|
231
192
|
erase();
|
|
232
193
|
};
|
|
@@ -234,7 +195,6 @@ async function menuSelect() {
|
|
|
234
195
|
input.setRawMode(true);
|
|
235
196
|
input.resume();
|
|
236
197
|
output.write("\x1b[?25l");
|
|
237
|
-
setMouse(output, true);
|
|
238
198
|
input.on("data", onData);
|
|
239
199
|
render();
|
|
240
200
|
};
|
|
@@ -243,7 +203,7 @@ async function menuSelect() {
|
|
|
243
203
|
input.pause();
|
|
244
204
|
resolve(value);
|
|
245
205
|
};
|
|
246
|
-
// 모델 셀렉터는 자체 raw-mode
|
|
206
|
+
// 모델 셀렉터는 자체 raw-mode 관리 — 메뉴 입력을 내려놓고 넘긴다.
|
|
247
207
|
// 선택되면 ollama처럼 즉시 실행으로 이어가고, 취소면 메뉴 복귀.
|
|
248
208
|
const configure = async (it) => {
|
|
249
209
|
suspend();
|
|
@@ -258,42 +218,18 @@ async function menuSelect() {
|
|
|
258
218
|
|
|
259
219
|
const feed = keyFeeder();
|
|
260
220
|
const onData = async (chunk) => {
|
|
261
|
-
let dirty = false;
|
|
221
|
+
let dirty = false;
|
|
262
222
|
for (const key of feed(chunk)) {
|
|
263
|
-
if (key.type === "ctrlc"
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
if (key.type === "cpr") {
|
|
267
|
-
cprRow = key.row;
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
223
|
+
if (key.type === "ctrlc") return finish("quit"); // 어느 메뉴에 있든 즉시 전체 종료
|
|
224
|
+
if (key.type === "escape" || (key.type === "char" && key.value === "q")) return finish(null); // 서브메뉴면 상위로, 최상위면 종료
|
|
270
225
|
if (key.type === "enter" || (key.type === "char" && key.value === " ")) {
|
|
271
226
|
return finish({ item: items[cursor] });
|
|
272
227
|
}
|
|
273
|
-
if (key.type === "
|
|
274
|
-
if (key.wheel === "up") move(-1);
|
|
275
|
-
else if (key.wheel === "down") move(1);
|
|
276
|
-
else if (key.motion) {
|
|
277
|
-
const idx = itemAt(key.y);
|
|
278
|
-
if (idx < 0 || idx === cursor) continue;
|
|
279
|
-
moveTo(idx);
|
|
280
|
-
} else if (!key.release && key.btn === 0) {
|
|
281
|
-
const idx = itemAt(key.y);
|
|
282
|
-
if (idx < 0) continue;
|
|
283
|
-
const wasMore = !expanded && items[idx]?.id === "more";
|
|
284
|
-
moveTo(idx);
|
|
285
|
-
if (!wasMore) return finish({ item: items[cursor] });
|
|
286
|
-
} else if (!key.release && key.btn === 2) {
|
|
287
|
-
const idx = itemAt(key.y);
|
|
288
|
-
if (idx < 0 || !items[idx].tool) continue;
|
|
289
|
-
moveTo(idx);
|
|
290
|
-
return void (await configure(items[cursor]));
|
|
291
|
-
} else continue;
|
|
292
|
-
} else if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
|
|
228
|
+
if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
|
|
293
229
|
else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
|
|
294
230
|
else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
|
|
295
231
|
const it = items[cursor];
|
|
296
|
-
if (!it.tool) continue;
|
|
232
|
+
if (!it.tool || it.manual) continue; // 도구 없는 항목·수동 설정 항목은 모델 설정 불가
|
|
297
233
|
return void (await configure(it));
|
|
298
234
|
} else continue;
|
|
299
235
|
dirty = true;
|
|
@@ -305,19 +241,42 @@ async function menuSelect() {
|
|
|
305
241
|
input.setRawMode(true);
|
|
306
242
|
input.resume();
|
|
307
243
|
output.write("\x1b[?25l");
|
|
308
|
-
setMouse(output, true);
|
|
309
244
|
input.on("data", onData);
|
|
310
245
|
render();
|
|
311
246
|
});
|
|
312
247
|
}
|
|
313
248
|
|
|
249
|
+
async function menuSelect() {
|
|
250
|
+
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
251
|
+
const cfg = readConfig();
|
|
252
|
+
const items = buildItems({ loggedIn: Boolean(cfg?.key), savedModels: cfg?.launch_models ?? {} });
|
|
253
|
+
const last = cfg?.last_selection;
|
|
254
|
+
const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
|
|
255
|
+
return runList(items, {
|
|
256
|
+
header: `${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
|
|
257
|
+
footer: t("↑/↓ move • enter run • → set model • esc quit"),
|
|
258
|
+
initialCursor,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function agentSelect() {
|
|
263
|
+
const installed = Object.fromEntries(
|
|
264
|
+
[...Object.entries(RECIPES), ...Object.entries(MANUAL)].map(([tool, r]) => [tool, onPath(r.bin)]),
|
|
265
|
+
);
|
|
266
|
+
const items = buildAgentItems({ installed, savedModels: readConfig()?.launch_models ?? {} });
|
|
267
|
+
return runList(items, {
|
|
268
|
+
header: style.bold(style.plum(t("Run an agent via PleumRouter"))),
|
|
269
|
+
footer: t("↑/↓ move • enter run • → set model • esc back"),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
314
273
|
// bare `pleum` 진입점. 비TTY면 false(호출자가 HELP 출력), 그 외 true.
|
|
315
|
-
// ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·
|
|
274
|
+
// ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·ctrl+c 종료 제외).
|
|
316
275
|
export async function mainMenu() {
|
|
317
276
|
for (;;) {
|
|
318
277
|
const picked = await menuSelect();
|
|
319
278
|
if (picked === undefined) return false; // 비TTY
|
|
320
|
-
if (picked === null) return true; //
|
|
279
|
+
if (picked === null || picked === "quit") return true; // esc/q/ctrl+c — ollama처럼 조용히 종료
|
|
321
280
|
|
|
322
281
|
const { item, model } = picked;
|
|
323
282
|
if (item.id !== "login") saveLastSelection(item.id);
|
|
@@ -348,10 +307,18 @@ export async function mainMenu() {
|
|
|
348
307
|
case "health":
|
|
349
308
|
await health();
|
|
350
309
|
continue;
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
354
|
-
|
|
310
|
+
case "agents": {
|
|
311
|
+
const agentPicked = await agentSelect();
|
|
312
|
+
if (agentPicked === "quit") return true; // 서브메뉴에서 ctrl+c → 전체 종료
|
|
313
|
+
if (!agentPicked) continue; // esc/q — 메인 메뉴로 복귀
|
|
314
|
+
const { item: agentItem, model: agentModel } = agentPicked;
|
|
315
|
+
if (agentModel && !agentItem.manual) saveModel(agentItem.tool, agentModel);
|
|
316
|
+
if (!agentItem.manual) {
|
|
317
|
+
const r = RECIPES[agentItem.tool];
|
|
318
|
+
if (!onPath(r.bin) && !(await ensureInstalled(r))) continue;
|
|
319
|
+
}
|
|
320
|
+
const agentSaved = readConfig()?.launch_models ?? {};
|
|
321
|
+
await launch(agentItem.tool, agentModel ?? agentSaved[agentItem.tool], [], { exit: false });
|
|
355
322
|
console.log(); // 에이전트 출력과 다음 메뉴 사이 여백
|
|
356
323
|
continue; // ollama처럼 에이전트 종료 후 메뉴 복귀
|
|
357
324
|
}
|
package/src/models.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { V1 } from "./config.mjs";
|
|
2
2
|
import { style, padDisplay, divider } from "./ansi.mjs";
|
|
3
|
+
import { t } from "./i18n.mjs";
|
|
3
4
|
|
|
4
5
|
// GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
|
|
5
6
|
export async function fetchModels() {
|
|
6
7
|
const res = await fetch(`${V1}/models`);
|
|
7
|
-
if (!res.ok) throw new Error(
|
|
8
|
+
if (!res.ok) throw new Error(t("Failed to fetch model list ({status})", { status: res.status }));
|
|
8
9
|
const data = await res.json();
|
|
9
10
|
return data.data ?? [];
|
|
10
11
|
}
|
|
@@ -84,7 +85,7 @@ export async function listModels(rest) {
|
|
|
84
85
|
: { json: false, info: false, query: rest };
|
|
85
86
|
if (info) {
|
|
86
87
|
if (!query) {
|
|
87
|
-
console.error("
|
|
88
|
+
console.error(t("Usage: pleum models <model-id> --info"));
|
|
88
89
|
process.exit(1);
|
|
89
90
|
}
|
|
90
91
|
return showModelInfo(query, { json });
|
|
@@ -96,19 +97,19 @@ export async function listModels(rest) {
|
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
98
99
|
if (!rows.length) {
|
|
99
|
-
console.log("
|
|
100
|
+
console.log(t("No matching models"));
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
103
|
console.log(formatTable(rows));
|
|
103
|
-
console.log(style.dim(`\n${rows.length} models ·
|
|
104
|
+
console.log(style.dim(`\n${rows.length} models · ${t("price = per 1M tokens (display rate; actual billing may vary slightly with the intraday rate)")}`));
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
// ── 모델 상세(ollama show 대응) — GET /v1/models/{id} ────────────────────
|
|
107
108
|
// id는 provider/model 형식이라 슬래시를 그대로 둔다(백엔드 :path 컨버터).
|
|
108
109
|
export async function fetchModelDetail(id) {
|
|
109
110
|
const res = await fetch(`${V1}/models/${id}`);
|
|
110
|
-
if (res.status === 404) throw new Error(
|
|
111
|
-
if (!res.ok) throw new Error(
|
|
111
|
+
if (res.status === 404) throw new Error(t("Model not found: {id} (list: pleum models)", { id }));
|
|
112
|
+
if (!res.ok) throw new Error(t("Failed to fetch model detail ({status})", { status: res.status }));
|
|
112
113
|
return res.json();
|
|
113
114
|
}
|
|
114
115
|
|
|
@@ -125,16 +126,16 @@ export function renderModelInfo(m, styler = style) {
|
|
|
125
126
|
["modality", m.modality || "text"],
|
|
126
127
|
["context", fmtCtx(m.context_length || m.context_window)],
|
|
127
128
|
["max output", fmtCtx(m.max_output_tokens || m.max_output)],
|
|
128
|
-
["
|
|
129
|
+
[t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: m.effective_markup_percent ?? 0 }))}`],
|
|
129
130
|
];
|
|
130
|
-
if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? "
|
|
131
|
+
if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? t("forced (can't be turned off)") : t("supported")]);
|
|
131
132
|
for (const [k, v] of facts) lines.push(`${styler.dim(padDisplay(k, 10))} ${v}`);
|
|
132
133
|
|
|
133
134
|
const u = m.usage;
|
|
134
135
|
if (u) {
|
|
135
136
|
lines.push(
|
|
136
|
-
`${styler.dim(padDisplay("
|
|
137
|
-
styler.dim(` (
|
|
137
|
+
`${styler.dim(padDisplay(t("usage"), 10))} ${t("weekly")} ${fmtNum(u.weekly_calls)} calls · ${fmtNum(u.weekly_tokens)} tok` +
|
|
138
|
+
styler.dim(` (${t("total")} ${fmtNum(u.total_calls)} calls)`),
|
|
138
139
|
);
|
|
139
140
|
}
|
|
140
141
|
|
|
@@ -161,7 +162,7 @@ export function renderModelInfo(m, styler = style) {
|
|
|
161
162
|
const sibs = (m.siblings || []).map((s) => s.id);
|
|
162
163
|
if (sibs.length) {
|
|
163
164
|
lines.push("");
|
|
164
|
-
lines.push(`${styler.dim("
|
|
165
|
+
lines.push(`${styler.dim(t("Same provider:"))} ${sibs.join(styler.dim(" · "))}`);
|
|
165
166
|
}
|
|
166
167
|
return lines.join("\n");
|
|
167
168
|
}
|
package/src/recipes.mjs
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { ROOT, V1 } from "./config.mjs";
|
|
2
|
+
import { t } from "./i18n.mjs";
|
|
2
3
|
|
|
3
|
-
// 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration.
|
|
4
|
+
// 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration + 에이전트별 공식 문서 조사(2026-07).
|
|
4
5
|
// type "env" → 환경변수만 세팅하고 바로 실행.
|
|
5
6
|
// type "codex-home" → 격리된 CODEX_HOME에 config.toml을 써서 실행(사용자 ~/.codex 보존).
|
|
7
|
+
//
|
|
8
|
+
// 커스텀 엔드포인트를 지원하지 않아 PleumRouter로 라우팅할 수 없는 도구는 등록하지 않는다(조사 결과, 2026-07):
|
|
9
|
+
// Gemini CLI(구글 백엔드 전용, PR#2899 머지 거부) · Cursor CLI(구독 백엔드 전용, 공식 확인) ·
|
|
10
|
+
// Amp(Sourcegraph 백엔드 전용) · OpenClaw(멀티채널 게이트웨이 데몬 — 세션형 코딩 CLI가 아님 + 파일설정 전용).
|
|
6
11
|
export const RECIPES = {
|
|
7
12
|
claude: {
|
|
8
13
|
label: "Claude Code",
|
|
@@ -18,6 +23,14 @@ export const RECIPES = {
|
|
|
18
23
|
}),
|
|
19
24
|
args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
|
|
20
25
|
},
|
|
26
|
+
codex: {
|
|
27
|
+
label: "Codex CLI",
|
|
28
|
+
bin: "codex",
|
|
29
|
+
type: "codex-home",
|
|
30
|
+
install: "npm i -g @openai/codex",
|
|
31
|
+
env: (key) => ({ PLEUM_API_KEY: key }),
|
|
32
|
+
args: () => [],
|
|
33
|
+
},
|
|
21
34
|
aider: {
|
|
22
35
|
label: "Aider",
|
|
23
36
|
bin: "aider",
|
|
@@ -31,7 +44,7 @@ export const RECIPES = {
|
|
|
31
44
|
bin: "goose",
|
|
32
45
|
type: "env",
|
|
33
46
|
install: "https://block.github.io/goose/docs/getting-started/installation",
|
|
34
|
-
note: "Goose
|
|
47
|
+
note: t("Goose sets its model in its own config — use the openai/<id> format in `goose configure`."),
|
|
35
48
|
env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
|
|
36
49
|
args: () => [],
|
|
37
50
|
},
|
|
@@ -40,16 +53,73 @@ export const RECIPES = {
|
|
|
40
53
|
bin: "openhands",
|
|
41
54
|
type: "env",
|
|
42
55
|
install: "python -m pip install openhands-ai",
|
|
43
|
-
note: "OpenHands
|
|
56
|
+
note: t("For OpenHands, prefix the model name with openai/ in its config."),
|
|
44
57
|
env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
|
|
45
58
|
args: () => [],
|
|
46
59
|
},
|
|
47
|
-
|
|
48
|
-
label: "
|
|
49
|
-
bin: "
|
|
50
|
-
type: "
|
|
51
|
-
install: "npm i -g @
|
|
52
|
-
|
|
60
|
+
opencode: {
|
|
61
|
+
label: "opencode",
|
|
62
|
+
bin: "opencode",
|
|
63
|
+
type: "env",
|
|
64
|
+
install: "npm i -g opencode-ai@latest",
|
|
65
|
+
// opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
|
|
66
|
+
// 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
|
|
67
|
+
note: t("Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched."),
|
|
68
|
+
env: (key, model) => ({
|
|
69
|
+
PLEUM_API_KEY: key,
|
|
70
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
71
|
+
provider: {
|
|
72
|
+
pleum: {
|
|
73
|
+
npm: "@ai-sdk/openai-compatible",
|
|
74
|
+
name: "PleumRouter",
|
|
75
|
+
options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
|
|
76
|
+
models: { [model || "gpt-4.1"]: {} },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
args: (m) => ["--model", `pleum/${m || "gpt-4.1"}`],
|
|
82
|
+
},
|
|
83
|
+
omp: {
|
|
84
|
+
label: "omp (Oh My Pi)",
|
|
85
|
+
bin: "omp",
|
|
86
|
+
type: "env",
|
|
87
|
+
install: "curl -fsSL https://omp.sh/install | sh",
|
|
88
|
+
// omp는 커스텀 OpenAI 호환 엔드포인트는 YAML 설정 파일로만 읽지만, 내장 anthropic
|
|
89
|
+
// provider만은 claude와 동일한 ANTHROPIC_BASE_URL env var로 리디렉션된다.
|
|
90
|
+
note: t("Needs the Bun runtime (bun >= 1.3.14) on PATH. Only Claude-family models are routed via env vars."),
|
|
91
|
+
env: (key) => ({ ANTHROPIC_BASE_URL: ROOT, ANTHROPIC_API_KEY: key }),
|
|
92
|
+
args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
|
|
93
|
+
},
|
|
94
|
+
hermes: {
|
|
95
|
+
label: "Hermes Agent",
|
|
96
|
+
bin: "hermes",
|
|
97
|
+
type: "env",
|
|
98
|
+
install: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
|
|
99
|
+
note: t("Pick the model after it starts with the `hermes model` command."),
|
|
100
|
+
env: (key) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key }),
|
|
101
|
+
args: () => [],
|
|
102
|
+
},
|
|
103
|
+
qwen: {
|
|
104
|
+
label: "Qwen Code",
|
|
105
|
+
bin: "qwen",
|
|
106
|
+
type: "env",
|
|
107
|
+
install: "npm install -g @qwen-code/qwen-code@latest",
|
|
108
|
+
env: (key, model) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key, OPENAI_MODEL: model || "gpt-4.1" }),
|
|
109
|
+
args: () => [],
|
|
110
|
+
},
|
|
111
|
+
copilot: {
|
|
112
|
+
label: "GitHub Copilot CLI",
|
|
113
|
+
bin: "copilot",
|
|
114
|
+
type: "env",
|
|
115
|
+
install: "npm install -g @github/copilot",
|
|
116
|
+
note: t("Needs GitHub Copilot CLI 2026-04 or later for BYOK provider routing support."),
|
|
117
|
+
env: (key, model) => ({
|
|
118
|
+
COPILOT_PROVIDER_BASE_URL: V1,
|
|
119
|
+
COPILOT_PROVIDER_TYPE: "openai",
|
|
120
|
+
COPILOT_PROVIDER_API_KEY: key,
|
|
121
|
+
COPILOT_MODEL: model || "gpt-4.1",
|
|
122
|
+
}),
|
|
53
123
|
args: () => [],
|
|
54
124
|
},
|
|
55
125
|
};
|
|
@@ -72,22 +142,10 @@ wire_api = "responses"
|
|
|
72
142
|
|
|
73
143
|
// provider 정의가 파일에 있어야 하는 도구 — 자동 실행 대신 설정 스니펫만 안내.
|
|
74
144
|
export const MANUAL = {
|
|
75
|
-
opencode: {
|
|
76
|
-
label: "OpenCode",
|
|
77
|
-
file: "opencode.json",
|
|
78
|
-
snippet: () => `{
|
|
79
|
-
"provider": {
|
|
80
|
-
"pleum": {
|
|
81
|
-
"npm": "@ai-sdk/openai-compatible",
|
|
82
|
-
"name": "PleumRouter",
|
|
83
|
-
"options": { "baseURL": "${V1}", "apiKey": "{env:PLEUM_API_KEY}" },
|
|
84
|
-
"models": { "gpt-4.1": {} }
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}`,
|
|
88
|
-
},
|
|
89
145
|
crush: {
|
|
90
146
|
label: "Crush",
|
|
147
|
+
bin: "crush",
|
|
148
|
+
install: "npm install -g @charmland/crush",
|
|
91
149
|
file: "crush.json",
|
|
92
150
|
snippet: () => `{
|
|
93
151
|
"providers": {
|