pleumrouter 0.3.1 → 0.5.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 +7 -5
- package/bin/pleum.mjs +54 -37
- package/package.json +1 -1
- package/src/balance.mjs +5 -4
- package/src/completion.mjs +10 -7
- package/src/config.mjs +13 -8
- package/src/health.mjs +9 -8
- package/src/i18n.mjs +37 -0
- package/src/interactive.mjs +25 -99
- package/src/lang.mjs +33 -0
- package/src/launch.mjs +18 -16
- package/src/locales/es.mjs +218 -0
- package/src/locales/ja.mjs +218 -0
- package/src/locales/ko.mjs +281 -0
- package/src/locales/zh.mjs +218 -0
- package/src/login.mjs +16 -15
- package/src/menu.mjs +129 -147
- package/src/models.mjs +12 -11
- package/src/recipes.mjs +83 -23
- package/src/run.mjs +44 -41
- package/src/settings.mjs +168 -0
- package/src/top.mjs +7 -6
- package/src/usage.mjs +10 -9
package/src/recipes.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { ROOT, V1 } from "./config.mjs";
|
|
2
2
|
|
|
3
|
-
// 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration.
|
|
3
|
+
// 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration + 에이전트별 공식 문서 조사(2026-07).
|
|
4
4
|
// type "env" → 환경변수만 세팅하고 바로 실행.
|
|
5
5
|
// type "codex-home" → 격리된 CODEX_HOME에 config.toml을 써서 실행(사용자 ~/.codex 보존).
|
|
6
|
+
// note 필드는 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점에 번역이
|
|
7
|
+
// 고정돼 같은 프로세스 안에서 언어를 바꿔도(Settings) 안내문이 갱신되지 않는다.
|
|
8
|
+
// 실제 번역은 소비 지점(launch.mjs)에서 매번 t()를 호출해 얻는다.
|
|
9
|
+
//
|
|
10
|
+
// 커스텀 엔드포인트를 지원하지 않아 PleumRouter로 라우팅할 수 없는 도구는 등록하지 않는다(조사 결과, 2026-07):
|
|
11
|
+
// Gemini CLI(구글 백엔드 전용, PR#2899 머지 거부) · Cursor CLI(구독 백엔드 전용, 공식 확인) ·
|
|
12
|
+
// Amp(Sourcegraph 백엔드 전용) · OpenClaw(멀티채널 게이트웨이 데몬 — 세션형 코딩 CLI가 아님 + 파일설정 전용).
|
|
6
13
|
export const RECIPES = {
|
|
7
14
|
claude: {
|
|
8
15
|
label: "Claude Code",
|
|
@@ -18,6 +25,14 @@ export const RECIPES = {
|
|
|
18
25
|
}),
|
|
19
26
|
args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
|
|
20
27
|
},
|
|
28
|
+
codex: {
|
|
29
|
+
label: "Codex CLI",
|
|
30
|
+
bin: "codex",
|
|
31
|
+
type: "codex-home",
|
|
32
|
+
install: "npm i -g @openai/codex",
|
|
33
|
+
env: (key) => ({ PLEUM_API_KEY: key }),
|
|
34
|
+
args: () => [],
|
|
35
|
+
},
|
|
21
36
|
aider: {
|
|
22
37
|
label: "Aider",
|
|
23
38
|
bin: "aider",
|
|
@@ -31,7 +46,7 @@ export const RECIPES = {
|
|
|
31
46
|
bin: "goose",
|
|
32
47
|
type: "env",
|
|
33
48
|
install: "https://block.github.io/goose/docs/getting-started/installation",
|
|
34
|
-
note: "Goose
|
|
49
|
+
note: "Goose sets its model in its own config — use the openai/<id> format in `goose configure`.",
|
|
35
50
|
env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
|
|
36
51
|
args: () => [],
|
|
37
52
|
},
|
|
@@ -40,16 +55,73 @@ export const RECIPES = {
|
|
|
40
55
|
bin: "openhands",
|
|
41
56
|
type: "env",
|
|
42
57
|
install: "python -m pip install openhands-ai",
|
|
43
|
-
note: "OpenHands
|
|
58
|
+
note: "For OpenHands, prefix the model name with openai/ in its config.",
|
|
44
59
|
env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
|
|
45
60
|
args: () => [],
|
|
46
61
|
},
|
|
47
|
-
|
|
48
|
-
label: "
|
|
49
|
-
bin: "
|
|
50
|
-
type: "
|
|
51
|
-
install: "npm i -g @
|
|
52
|
-
|
|
62
|
+
opencode: {
|
|
63
|
+
label: "opencode",
|
|
64
|
+
bin: "opencode",
|
|
65
|
+
type: "env",
|
|
66
|
+
install: "npm i -g opencode-ai@latest",
|
|
67
|
+
// opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
|
|
68
|
+
// 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
|
|
69
|
+
note: "Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched.",
|
|
70
|
+
env: (key, model) => ({
|
|
71
|
+
PLEUM_API_KEY: key,
|
|
72
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
73
|
+
provider: {
|
|
74
|
+
pleum: {
|
|
75
|
+
npm: "@ai-sdk/openai-compatible",
|
|
76
|
+
name: "PleumRouter",
|
|
77
|
+
options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
|
|
78
|
+
models: { [model || "gpt-4.1"]: {} },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
}),
|
|
83
|
+
args: (m) => ["--model", `pleum/${m || "gpt-4.1"}`],
|
|
84
|
+
},
|
|
85
|
+
omp: {
|
|
86
|
+
label: "omp (Oh My Pi)",
|
|
87
|
+
bin: "omp",
|
|
88
|
+
type: "env",
|
|
89
|
+
install: "curl -fsSL https://omp.sh/install | sh",
|
|
90
|
+
// omp는 커스텀 OpenAI 호환 엔드포인트는 YAML 설정 파일로만 읽지만, 내장 anthropic
|
|
91
|
+
// provider만은 claude와 동일한 ANTHROPIC_BASE_URL env var로 리디렉션된다.
|
|
92
|
+
note: "Needs the Bun runtime (bun >= 1.3.14) on PATH. Only Claude-family models are routed via env vars.",
|
|
93
|
+
env: (key) => ({ ANTHROPIC_BASE_URL: ROOT, ANTHROPIC_API_KEY: key }),
|
|
94
|
+
args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
|
|
95
|
+
},
|
|
96
|
+
hermes: {
|
|
97
|
+
label: "Hermes Agent",
|
|
98
|
+
bin: "hermes",
|
|
99
|
+
type: "env",
|
|
100
|
+
install: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
|
|
101
|
+
note: "Pick the model after it starts with the `hermes model` command.",
|
|
102
|
+
env: (key) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key }),
|
|
103
|
+
args: () => [],
|
|
104
|
+
},
|
|
105
|
+
qwen: {
|
|
106
|
+
label: "Qwen Code",
|
|
107
|
+
bin: "qwen",
|
|
108
|
+
type: "env",
|
|
109
|
+
install: "npm install -g @qwen-code/qwen-code@latest",
|
|
110
|
+
env: (key, model) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key, OPENAI_MODEL: model || "gpt-4.1" }),
|
|
111
|
+
args: () => [],
|
|
112
|
+
},
|
|
113
|
+
copilot: {
|
|
114
|
+
label: "GitHub Copilot CLI",
|
|
115
|
+
bin: "copilot",
|
|
116
|
+
type: "env",
|
|
117
|
+
install: "npm install -g @github/copilot",
|
|
118
|
+
note: "Needs GitHub Copilot CLI 2026-04 or later for BYOK provider routing support.",
|
|
119
|
+
env: (key, model) => ({
|
|
120
|
+
COPILOT_PROVIDER_BASE_URL: V1,
|
|
121
|
+
COPILOT_PROVIDER_TYPE: "openai",
|
|
122
|
+
COPILOT_PROVIDER_API_KEY: key,
|
|
123
|
+
COPILOT_MODEL: model || "gpt-4.1",
|
|
124
|
+
}),
|
|
53
125
|
args: () => [],
|
|
54
126
|
},
|
|
55
127
|
};
|
|
@@ -72,22 +144,10 @@ wire_api = "responses"
|
|
|
72
144
|
|
|
73
145
|
// provider 정의가 파일에 있어야 하는 도구 — 자동 실행 대신 설정 스니펫만 안내.
|
|
74
146
|
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
147
|
crush: {
|
|
90
148
|
label: "Crush",
|
|
149
|
+
bin: "crush",
|
|
150
|
+
install: "npm install -g @charmland/crush",
|
|
91
151
|
file: "crush.json",
|
|
92
152
|
snippet: () => `{
|
|
93
153
|
"providers": {
|
package/src/run.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fetchModels, formatTable, matchModels } from "./models.mjs";
|
|
|
6
6
|
import { style, divider } from "./ansi.mjs";
|
|
7
7
|
import { createMarkdownStream } from "./markdown.mjs";
|
|
8
8
|
import { spinner, select } from "./interactive.mjs";
|
|
9
|
+
import { t } from "./i18n.mjs";
|
|
9
10
|
|
|
10
11
|
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
11
12
|
|
|
@@ -67,14 +68,14 @@ export async function run(rest) {
|
|
|
67
68
|
if (!model && !oneShot) {
|
|
68
69
|
if (!process.stdin.isTTY) {
|
|
69
70
|
// 비대화형(non-TTY) stdin이면 인터랙티브 모델 선택이 멈춰버리므로 명확히 안내하고 종료(L10).
|
|
70
|
-
console.error('
|
|
71
|
+
console.error(t('Specify a model: pleum run <model> ["prompt"] (model selection isn\'t available on non-interactive stdin)'));
|
|
71
72
|
process.exit(1);
|
|
72
73
|
}
|
|
73
74
|
model = await pickModel(); // 인터랙티브 모델 선택
|
|
74
75
|
if (!model) process.exit(1);
|
|
75
76
|
}
|
|
76
77
|
if (!model) {
|
|
77
|
-
console.error('
|
|
78
|
+
console.error(t('Usage: pleum run <model> ["prompt"]\nModel list: pleum models'));
|
|
78
79
|
process.exit(1);
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -84,7 +85,7 @@ export async function run(rest) {
|
|
|
84
85
|
if (Array.isArray(content)) {
|
|
85
86
|
const n = content.filter((p) => p.type === "image_url").length;
|
|
86
87
|
// 프롬프트 내 로컬 이미지 경로를 자동 첨부·업로드하므로 조용히 올리지 않고 명시 고지한다(M8).
|
|
87
|
-
console.error(`ℹ
|
|
88
|
+
console.error(`ℹ ${t("Attaching and uploading {n} local image(s) from the prompt.", { n })}`);
|
|
88
89
|
}
|
|
89
90
|
const messages = [{ role: "user", content }];
|
|
90
91
|
try {
|
|
@@ -204,7 +205,7 @@ async function repl(key, initialModel, initialVerbose) {
|
|
|
204
205
|
const onClose = () => {
|
|
205
206
|
if (pickerActive) return;
|
|
206
207
|
saveHistory(rl.history);
|
|
207
|
-
console.log(`\n${style.dim("
|
|
208
|
+
console.log(`\n${style.dim(t("Goodbye."))}`);
|
|
208
209
|
process.exit(0);
|
|
209
210
|
};
|
|
210
211
|
|
|
@@ -220,7 +221,7 @@ async function repl(key, initialModel, initialVerbose) {
|
|
|
220
221
|
return;
|
|
221
222
|
}
|
|
222
223
|
lastSigint = now;
|
|
223
|
-
console.log(`\n${style.dim("
|
|
224
|
+
console.log(`\n${style.dim(t("Ctrl+C again to quit (or /exit)"))}`);
|
|
224
225
|
rl.prompt();
|
|
225
226
|
};
|
|
226
227
|
|
|
@@ -240,23 +241,25 @@ async function repl(key, initialModel, initialVerbose) {
|
|
|
240
241
|
|
|
241
242
|
makeRl(loadHistory());
|
|
242
243
|
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
|
|
243
|
-
console.log(style.dim('/help
|
|
244
|
+
console.log(style.dim(t('/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation')));
|
|
244
245
|
console.log(divider());
|
|
245
246
|
rl.prompt();
|
|
246
247
|
}
|
|
247
248
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
/
|
|
252
|
-
/
|
|
253
|
-
/
|
|
254
|
-
/
|
|
255
|
-
/
|
|
256
|
-
/
|
|
257
|
-
/
|
|
258
|
-
|
|
259
|
-
|
|
249
|
+
// t() 키(영어 원문)만 담는다 — 모듈 로드 시점에 t()를 호출하면 같은 프로세스 안에서
|
|
250
|
+
// 언어를 바꿔도(Settings) 이 도움말만 예전 언어로 남는다. 실제 번역은 소비 지점(handleSlash)에서.
|
|
251
|
+
const SLASH_HELP = `Commands:
|
|
252
|
+
/help, /? this help
|
|
253
|
+
/clear reset the conversation context (keeps the system prompt)
|
|
254
|
+
/system [text] set the system prompt (no arg shows the current value)
|
|
255
|
+
/model [id] switch models mid-session (no arg = arrow-key picker)
|
|
256
|
+
/models [query] browse the model list
|
|
257
|
+
/save [name] save the current session (default: last)
|
|
258
|
+
/load [name] load a saved session
|
|
259
|
+
/verbose toggle token/speed stats
|
|
260
|
+
/exit, /quit quit
|
|
261
|
+
Multi-line input: a line with """ → body → a line with """
|
|
262
|
+
Images: include an image file path right in the message (e.g. describe this ./shot.png)`;
|
|
260
263
|
|
|
261
264
|
async function handleSlash(text, ctx) {
|
|
262
265
|
const [cmd] = text.slice(1).split(/\s+/);
|
|
@@ -264,41 +267,41 @@ async function handleSlash(text, ctx) {
|
|
|
264
267
|
switch (cmd) {
|
|
265
268
|
case "help":
|
|
266
269
|
case "?":
|
|
267
|
-
console.log(SLASH_HELP);
|
|
270
|
+
console.log(t(SLASH_HELP));
|
|
268
271
|
break;
|
|
269
272
|
case "clear":
|
|
270
273
|
ctx.clear();
|
|
271
|
-
console.log(`${style.green("✓")}
|
|
274
|
+
console.log(`${style.green("✓")} ${t("Context cleared")}`);
|
|
272
275
|
break;
|
|
273
276
|
case "system":
|
|
274
277
|
if (arg) {
|
|
275
278
|
ctx.system = arg;
|
|
276
|
-
console.log(`${style.green("✓")}
|
|
279
|
+
console.log(`${style.green("✓")} ${t("System prompt set")}`);
|
|
277
280
|
} else {
|
|
278
|
-
console.log(ctx.system ?
|
|
281
|
+
console.log(ctx.system ? `${t("Current system prompt:")}\n${ctx.system}` : t("No system prompt"));
|
|
279
282
|
}
|
|
280
283
|
break;
|
|
281
284
|
case "model":
|
|
282
285
|
if (arg) {
|
|
283
286
|
ctx.model = arg;
|
|
284
|
-
console.log(`${style.green("✓")}
|
|
287
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(arg)}`);
|
|
285
288
|
} else if (ctx.pickModel) {
|
|
286
289
|
const picked = await ctx.pickModel();
|
|
287
290
|
if (picked) {
|
|
288
291
|
ctx.model = picked;
|
|
289
|
-
console.log(`${style.green("✓")}
|
|
292
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(picked)}`);
|
|
290
293
|
} else {
|
|
291
|
-
console.log(
|
|
294
|
+
console.log(`${t("Current model:")} ${style.plum(ctx.model)}`);
|
|
292
295
|
}
|
|
293
296
|
} else {
|
|
294
|
-
console.log(
|
|
297
|
+
console.log(`${t("Current model:")} ${ctx.model}\n${t("switch: /model <id> · list: /models")}`);
|
|
295
298
|
}
|
|
296
299
|
break;
|
|
297
300
|
case "models":
|
|
298
301
|
try {
|
|
299
302
|
const models = await fetchModels();
|
|
300
303
|
const rows = matchModels(models, arg);
|
|
301
|
-
console.log(rows.length ? formatTable(rows) : "
|
|
304
|
+
console.log(rows.length ? formatTable(rows) : t("No matching models"));
|
|
302
305
|
} catch (e) {
|
|
303
306
|
console.error(`${style.red("✗")} ${e.message}`);
|
|
304
307
|
}
|
|
@@ -306,25 +309,25 @@ async function handleSlash(text, ctx) {
|
|
|
306
309
|
case "save":
|
|
307
310
|
try {
|
|
308
311
|
const name = saveSession(arg || "last", ctx.snapshot());
|
|
309
|
-
console.log(`${style.green("✓")}
|
|
312
|
+
console.log(`${style.green("✓")} ${t("Session saved:")} ${name}`);
|
|
310
313
|
} catch (e) {
|
|
311
|
-
console.error(`${style.red("✗")}
|
|
314
|
+
console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: e.message })}`);
|
|
312
315
|
}
|
|
313
316
|
break;
|
|
314
317
|
case "load":
|
|
315
318
|
try {
|
|
316
319
|
ctx.restore(loadSession(arg || "last"));
|
|
317
|
-
console.log(`${style.green("✓")}
|
|
320
|
+
console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: ctx.model })}`);
|
|
318
321
|
} catch {
|
|
319
|
-
console.error(`${style.red("✗")}
|
|
322
|
+
console.error(`${style.red("✗")} ${t("No such session: {name}", { name: arg || "last" })}`);
|
|
320
323
|
}
|
|
321
324
|
break;
|
|
322
325
|
case "verbose":
|
|
323
326
|
ctx.verbose = !ctx.verbose;
|
|
324
|
-
console.log(`${style.green("✓")}
|
|
327
|
+
console.log(`${style.green("✓")} ${t("Stats {state}", { state: ctx.verbose ? t("on") : t("off") })}`);
|
|
325
328
|
break;
|
|
326
329
|
default:
|
|
327
|
-
console.log(
|
|
330
|
+
console.log(t("Unknown command: /{cmd} (/help)", { cmd }));
|
|
328
331
|
}
|
|
329
332
|
}
|
|
330
333
|
|
|
@@ -332,7 +335,7 @@ async function handleSlash(text, ctx) {
|
|
|
332
335
|
// 비용은 마지막 SSE 청크의 cost.krw — 스트리밍 응답엔 X-Cost-Krw 헤더가 없다(백엔드 계약).
|
|
333
336
|
export async function streamChat(key, model, messages, { verbose = false, quiet = false, signal, fetchImpl = fetch } = {}) {
|
|
334
337
|
const t0 = Date.now();
|
|
335
|
-
const sp = spinner(
|
|
338
|
+
const sp = spinner(t("{model} thinking…", { model }));
|
|
336
339
|
let res;
|
|
337
340
|
try {
|
|
338
341
|
res = await fetchImpl(`${V1}/chat/completions`, {
|
|
@@ -344,7 +347,7 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
|
|
|
344
347
|
} catch (e) {
|
|
345
348
|
sp.stop();
|
|
346
349
|
if (isAbort(e)) {
|
|
347
|
-
console.error(style.dim("⏹
|
|
350
|
+
console.error(style.dim(t("⏹ aborted")));
|
|
348
351
|
return "";
|
|
349
352
|
}
|
|
350
353
|
throw e;
|
|
@@ -400,7 +403,7 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
|
|
|
400
403
|
}
|
|
401
404
|
sp.stop();
|
|
402
405
|
md.finish();
|
|
403
|
-
if (aborted) console.error(style.dim("⏹
|
|
406
|
+
if (aborted) console.error(style.dim(t("⏹ generation aborted — the partial reply stays in context")));
|
|
404
407
|
|
|
405
408
|
const ms = Date.now() - t0;
|
|
406
409
|
const genSec = (Date.now() - (firstAt || t0)) / 1000;
|
|
@@ -421,13 +424,13 @@ async function pickModel() {
|
|
|
421
424
|
try {
|
|
422
425
|
models = await fetchModels();
|
|
423
426
|
} catch (e) {
|
|
424
|
-
console.error(`${style.red("✗")}
|
|
427
|
+
console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
425
428
|
return null;
|
|
426
429
|
}
|
|
427
430
|
if (!models.length) return null;
|
|
428
431
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
429
432
|
return select(models, {
|
|
430
|
-
message: "
|
|
433
|
+
message: t("Select a model"),
|
|
431
434
|
formatItem: (m) => `${m.id}${m.display_name ? ` ${style.dim(m.display_name)}` : ""}`,
|
|
432
435
|
matches: matchModels,
|
|
433
436
|
limit: 12,
|
|
@@ -447,14 +450,14 @@ async function pickModelNumbered(models) {
|
|
|
447
450
|
shown.forEach((m, i) =>
|
|
448
451
|
console.log(` ${String(i + 1).padStart(2)}. ${m.id}${m.display_name ? ` (${m.display_name})` : ""}`),
|
|
449
452
|
);
|
|
450
|
-
if (pool.length > shown.length) console.log(` …
|
|
451
|
-
const ans = (await ask("
|
|
453
|
+
if (pool.length > shown.length) console.log(` ${t("… {n} more — narrow with a search term", { n: pool.length - shown.length })}`);
|
|
454
|
+
const ans = (await ask(`\n${t("Choose a number, or search (Enter to cancel): ")}`)).trim();
|
|
452
455
|
if (!ans) return null;
|
|
453
456
|
const n = Number(ans);
|
|
454
457
|
if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1].id;
|
|
455
458
|
const next = matchModels(models, ans);
|
|
456
459
|
pool = next.length ? next : models;
|
|
457
|
-
if (!next.length) console.log("
|
|
460
|
+
if (!next.length) console.log(t("No matches — showing the full list."));
|
|
458
461
|
}
|
|
459
462
|
} finally {
|
|
460
463
|
rl.close();
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// 런처 Settings 서브메뉴 — agentSelect()(menu.mjs)와 동일한 "한 항목 → 서브메뉴" 패턴.
|
|
2
|
+
// menu.mjs의 runList/saveModel을 가져오고 menu.mjs는 이 모듈의 runSettings를 가져오는
|
|
3
|
+
// 순환 import가 있다 — 둘 다 함수 선언(export function)이라 ESM 인스턴스화 단계에서
|
|
4
|
+
// 먼저 호이스팅되므로 안전하다. 다만 앞으로 이 파일을 고칠 때도 두 모듈 모두 상대방의
|
|
5
|
+
// export를 "async 함수 바디 안에서만" 참조하는 규칙은 유지할 것(모듈 최상위에서 값으로
|
|
6
|
+
// 즉시 참조하면 순환의 평가 순서에 따라 깨질 수 있다).
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { style } from "./ansi.mjs";
|
|
10
|
+
import { select, confirm } from "./interactive.mjs";
|
|
11
|
+
import { readConfig, writeConfig, clearCredentials } from "./config.mjs";
|
|
12
|
+
import { getLocale, LANGS, t } from "./i18n.mjs";
|
|
13
|
+
import { RECIPES } from "./recipes.mjs";
|
|
14
|
+
import { fetchModels, matchModels } from "./models.mjs";
|
|
15
|
+
import { login } from "./login.mjs";
|
|
16
|
+
import { upgrade } from "./upgrade.mjs";
|
|
17
|
+
import { completion } from "./completion.mjs";
|
|
18
|
+
import { lang } from "./lang.mjs";
|
|
19
|
+
import { runList, saveModel } from "./menu.mjs";
|
|
20
|
+
|
|
21
|
+
// Settings 서브메뉴 항목(순수 함수 — 테스트 대상).
|
|
22
|
+
export function buildSettingsItems({ lang, loggedIn, keyPrefix, version }) {
|
|
23
|
+
return [
|
|
24
|
+
{ id: "settings:lang", title: t("Language"), desc: `${t("current")}: ${LANGS[lang] ?? lang}` },
|
|
25
|
+
{ id: "settings:models", title: t("Default models"), desc: t("Set the default model used per agent") },
|
|
26
|
+
{
|
|
27
|
+
id: "settings:account",
|
|
28
|
+
title: t("Account"),
|
|
29
|
+
desc: loggedIn ? `${t("Logged in")} (${t("key")} ${keyPrefix}…)` : t("Not logged in"),
|
|
30
|
+
},
|
|
31
|
+
{ id: "settings:update", title: t("Update CLI"), desc: t("Current version: {version}", { version }) },
|
|
32
|
+
{ id: "settings:completion", title: t("Shell completion"), desc: t("Print a zsh/bash completion script") },
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "Default models" 서브메뉴 항목(순수 함수 — 테스트 대상). chat + RECIPES 전부.
|
|
37
|
+
// 필드명은 의도적으로 `tool`이 아니라 `toolId` — runList()의 → 키바인딩은 `it.tool`이
|
|
38
|
+
// 있으면 자동으로 menu.mjs의 pickModelFor(클리어 옵션 없음)를 띄운다. 이 화면은 Enter로만
|
|
39
|
+
// 진입해 "Use default (clear)"가 있는 자체 피커(pickDefaultModelFor)를 쓰므로 `tool`을
|
|
40
|
+
// 피해 → 를 비활성 상태로 둔다(그래야 Enter 흐름과 중복 실행되지 않는다).
|
|
41
|
+
export function buildDefaultModelItems({ savedModels = {} }) {
|
|
42
|
+
const tools = ["chat", ...Object.keys(RECIPES)];
|
|
43
|
+
return tools.map((tool) => ({
|
|
44
|
+
id: `defaultmodel:${tool}`,
|
|
45
|
+
toolId: tool,
|
|
46
|
+
title: tool === "chat" ? t("Chat with a model") : RECIPES[tool].label,
|
|
47
|
+
desc: savedModels[tool] ? `${t("model")}: ${savedModels[tool]}` : t("not set"),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readVersion() {
|
|
52
|
+
// bin/pleum.mjs의 version()과 동일한 1줄 패턴 복제 — bin/pleum.mjs는 entry-guard 없이
|
|
53
|
+
// process.argv 스위치를 최상위에서 바로 실행하는 진입점이라 import하면 CLI 디스패치가
|
|
54
|
+
// 함께 실행돼 버린다. menuSelect()도 같은 이유로 이 패턴을 복제해 쓰고 있다.
|
|
55
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clearSavedModel(tool) {
|
|
59
|
+
const cfg = readConfig() ?? {};
|
|
60
|
+
if (cfg.launch_models) delete cfg.launch_models[tool];
|
|
61
|
+
writeConfig(cfg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function settingsSelect() {
|
|
65
|
+
const cfg = readConfig();
|
|
66
|
+
const items = buildSettingsItems({
|
|
67
|
+
lang: getLocale(),
|
|
68
|
+
loggedIn: Boolean(cfg?.key),
|
|
69
|
+
keyPrefix: cfg?.key ? cfg.key.slice(0, 8) : null,
|
|
70
|
+
version: readVersion(),
|
|
71
|
+
});
|
|
72
|
+
return runList(items, {
|
|
73
|
+
header: style.bold(style.plum(t("Settings"))),
|
|
74
|
+
footer: t("↑/↓ move • enter select • esc back"),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const CLEAR_OPTION = { id: "__clear__", display_name: null, isClear: true };
|
|
79
|
+
|
|
80
|
+
async function pickDefaultModelFor(item) {
|
|
81
|
+
let models;
|
|
82
|
+
try {
|
|
83
|
+
models = await fetchModels();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const picked = await select([CLEAR_OPTION, ...models], {
|
|
89
|
+
message: `${item.title} — ${t("select a model")}`,
|
|
90
|
+
formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) : `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`),
|
|
91
|
+
matches: matchModels, // 쿼리 입력 시 CLEAR_OPTION은 자연히 필터링됨(id/display_name 불일치) — 별도 처리 불요
|
|
92
|
+
limit: 12,
|
|
93
|
+
});
|
|
94
|
+
return picked ?? undefined; // select()는 취소 시 null — pickModelFor 관례처럼 undefined로 정규화
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runDefaultModels() {
|
|
98
|
+
for (;;) {
|
|
99
|
+
const items = buildDefaultModelItems({ savedModels: readConfig()?.launch_models ?? {} });
|
|
100
|
+
const picked = await runList(items, {
|
|
101
|
+
header: style.bold(style.plum(t("Default models"))),
|
|
102
|
+
footer: t("↑/↓ move • enter set model • esc back"),
|
|
103
|
+
});
|
|
104
|
+
if (picked === undefined) return undefined;
|
|
105
|
+
if (picked === null) return null;
|
|
106
|
+
if (picked === "quit") return "quit";
|
|
107
|
+
const result = await pickDefaultModelFor(picked.item);
|
|
108
|
+
if (!result) continue; // 취소/조회 실패 — 목록 다시 표시
|
|
109
|
+
if (result.isClear) clearSavedModel(picked.item.toolId);
|
|
110
|
+
else saveModel(picked.item.toolId, result.id);
|
|
111
|
+
// 메인 런처의 →(설정 즉시 실행)와 의도적으로 다르다 — 저장만 하고 목록으로 복귀.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runAccount() {
|
|
116
|
+
const cfg = readConfig();
|
|
117
|
+
if (cfg?.key) {
|
|
118
|
+
const ok = await confirm(t("Log out? [Y/n] "), { defaultYes: true });
|
|
119
|
+
if (ok) {
|
|
120
|
+
writeConfig(clearCredentials(cfg));
|
|
121
|
+
console.log(`✓ ${t("Logged out")}`);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
await login();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runCompletion() {
|
|
129
|
+
const picked = await select(
|
|
130
|
+
[{ id: "zsh", label: "zsh" }, { id: "bash", label: "bash" }],
|
|
131
|
+
{ message: t("Choose a shell"), formatItem: (x) => x.label },
|
|
132
|
+
);
|
|
133
|
+
if (picked) completion(picked.id); // 출력만 — rc 파일 자동 수정 없음(기존 completion.mjs 그대로)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Settings 서브메뉴 루프. 반환값은 runList 규약을 그대로 전파: "quit"(ctrl+c 또는 재설치
|
|
137
|
+
// 시작) · null(esc/q, 메인 메뉴로) · undefined(비TTY).
|
|
138
|
+
export async function runSettings() {
|
|
139
|
+
for (;;) {
|
|
140
|
+
const picked = await settingsSelect();
|
|
141
|
+
if (picked === undefined) return undefined;
|
|
142
|
+
if (picked === null) return null;
|
|
143
|
+
if (picked === "quit") return "quit";
|
|
144
|
+
switch (picked.item.id) {
|
|
145
|
+
case "settings:lang":
|
|
146
|
+
await lang([]); // TTY 화살표 피커 재사용 — setLocale()이 i18n 캐시를 즉시 갱신하므로
|
|
147
|
+
continue; // 다음 루프의 buildSettingsItems() 재빌드로 새 언어가 자연히 반영된다
|
|
148
|
+
case "settings:models": {
|
|
149
|
+
const r = await runDefaultModels();
|
|
150
|
+
if (r === "quit") return "quit";
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
case "settings:account":
|
|
154
|
+
await runAccount();
|
|
155
|
+
continue;
|
|
156
|
+
case "settings:update":
|
|
157
|
+
// upgrade()는 자식(npm install) 종료 시 process.exit()을 호출한다. 여기서 continue해
|
|
158
|
+
// 메뉴를 다시 그리면 raw-mode 화면과 npm 출력이 뒤섞이다 프로세스가 갑자기 죽으므로,
|
|
159
|
+
// mainMenu()의 chat 케이스처럼 즉시 루프를 벗어난다.
|
|
160
|
+
console.log(t("Reinstalling — pleum will exit when finished. Run `pleum` again afterward."));
|
|
161
|
+
upgrade();
|
|
162
|
+
return "quit";
|
|
163
|
+
case "settings:completion":
|
|
164
|
+
await runCompletion();
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/top.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { V1 } from "./config.mjs";
|
|
5
5
|
import { style, padDisplay, barChart, divider } from "./ansi.mjs";
|
|
6
|
+
import { t } from "./i18n.mjs";
|
|
6
7
|
|
|
7
8
|
export function parseTopArgs(rest = []) {
|
|
8
9
|
let period = "weekly";
|
|
@@ -11,7 +12,7 @@ export function parseTopArgs(rest = []) {
|
|
|
11
12
|
else if (["daily", "weekly", "monthly"].includes(rest[i])) period = rest[i];
|
|
12
13
|
}
|
|
13
14
|
if (!["daily", "weekly", "monthly"].includes(period)) {
|
|
14
|
-
throw new Error(
|
|
15
|
+
throw new Error(t("period must be one of daily|weekly|monthly: {period}", { period }));
|
|
15
16
|
}
|
|
16
17
|
return { period };
|
|
17
18
|
}
|
|
@@ -25,14 +26,14 @@ const fmtTok = (n) => {
|
|
|
25
26
|
|
|
26
27
|
// rankings 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
27
28
|
export function renderTop(data, styler = style) {
|
|
28
|
-
const
|
|
29
|
+
const periodLabel = { daily: t("daily"), weekly: t("weekly"), monthly: t("monthly") }[data.period] || data.period;
|
|
29
30
|
const lines = [];
|
|
30
|
-
lines.push(`${styler.bold(styler.plum("
|
|
31
|
+
lines.push(`${styler.bold(styler.plum(t("Model leaderboard")))} ${styler.dim(`· ${periodLabel} · ${t("tokens")} ${fmtTok(data.total_tokens ?? 0)}`)}`);
|
|
31
32
|
lines.push(divider(undefined, styler));
|
|
32
33
|
|
|
33
34
|
const board = data.leaderboard || [];
|
|
34
35
|
if (!board.length) {
|
|
35
|
-
lines.push(styler.dim("
|
|
36
|
+
lines.push(styler.dim(t("No data")));
|
|
36
37
|
} else {
|
|
37
38
|
const idW = Math.max(...board.map((r) => r.model.length), 5);
|
|
38
39
|
for (const r of board) {
|
|
@@ -53,7 +54,7 @@ export function renderTop(data, styler = style) {
|
|
|
53
54
|
const share = data.market_share || [];
|
|
54
55
|
if (share.length) {
|
|
55
56
|
lines.push("");
|
|
56
|
-
lines.push(styler.bold("
|
|
57
|
+
lines.push(styler.bold(t("Provider share")));
|
|
57
58
|
const nameW = Math.max(...share.map((s) => s.provider.length), 8);
|
|
58
59
|
for (const s of share) {
|
|
59
60
|
lines.push(
|
|
@@ -67,6 +68,6 @@ export function renderTop(data, styler = style) {
|
|
|
67
68
|
export async function top(rest) {
|
|
68
69
|
const { period } = parseTopArgs(rest);
|
|
69
70
|
const res = await fetch(`${V1}/stats/rankings?period=${period}`);
|
|
70
|
-
if (!res.ok) throw new Error(
|
|
71
|
+
if (!res.ok) throw new Error(t("Failed to fetch leaderboard ({status})", { status: res.status }));
|
|
71
72
|
console.log(renderTop(await res.json()));
|
|
72
73
|
}
|