pleumrouter 0.5.30 → 0.5.32

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/src/recipes.mjs CHANGED
@@ -7,12 +7,12 @@ export const CORE_MODELS = [
7
7
  "claude-sonnet-5",
8
8
  "claude-opus-5-5",
9
9
  "gpt-6-astra",
10
- "claude-fable-5-1",
10
+ "gpt-6-sol",
11
+ "gpt-6-luna",
11
12
  "gemini-3.8-flash",
13
+ "qwen3.8-max",
12
14
  "glm-5.3-flash",
13
15
  "kimi-k3",
14
- "gpt-6-sol",
15
- "gpt-6-luna",
16
16
  ];
17
17
 
18
18
  // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/{codex,claude-code,...} + 에이전트별 공식 문서.
@@ -42,14 +42,29 @@ export const RECIPES = {
42
42
  },
43
43
  // 함정: ANTHROPIC_BASE_URL은 반드시 루트(/v1 없이) — CLI가 /v1/messages를 스스로 덧붙인다.
44
44
  // /v1을 넣으면 /v1/v1/messages가 되어 실패.
45
- env: (key) => ({
46
- ANTHROPIC_BASE_URL: ROOT,
47
- ANTHROPIC_API_KEY: key,
48
- ANTHROPIC_AUTH_TOKEN: key,
49
- // /model 이 GET {ANTHROPIC_BASE_URL}/v1/models 를 치게 한다. 기본값은 꺼져 있다.
50
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
51
- }),
52
- args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("anthropic/") ? (m || DEFAULT_MODEL) : `anthropic/${m || DEFAULT_MODEL}`],
45
+ env: (key, m) => {
46
+ const isObj = m && typeof m === "object";
47
+ const sonnet = (isObj ? m.sonnet : m) || "claude-sonnet-5";
48
+ const opus = (isObj ? m.opus : null) || "claude-opus-5-5";
49
+ const haiku = (isObj ? m.haiku : null) || DEFAULT_MODEL;
50
+ const toAnthropic = (x) => (x.startsWith("anthropic/") ? x : `anthropic/${x}`);
51
+ return {
52
+ ANTHROPIC_BASE_URL: ROOT,
53
+ ANTHROPIC_API_KEY: key,
54
+ ANTHROPIC_AUTH_TOKEN: key,
55
+ // /model 이 GET {ANTHROPIC_BASE_URL}/v1/models 를 치게 한다. 기본값은 꺼져 있다.
56
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
57
+ ANTHROPIC_DEFAULT_SONNET_MODEL: toAnthropic(sonnet),
58
+ ANTHROPIC_DEFAULT_OPUS_MODEL: toAnthropic(opus),
59
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: toAnthropic(haiku),
60
+ ANTHROPIC_SMALL_MODEL: toAnthropic(haiku),
61
+ };
62
+ },
63
+ args: (m) => {
64
+ const isObj = m && typeof m === "object";
65
+ const main = (isObj ? m.sonnet : m) || DEFAULT_MODEL;
66
+ return ["--model", main.startsWith("anthropic/") ? main : `anthropic/${main}`];
67
+ },
53
68
  },
54
69
  codex: {
55
70
  label: "Codex CLI",
@@ -61,7 +76,67 @@ export const RECIPES = {
61
76
  referer: "https://github.com/openai/codex",
62
77
  },
63
78
  env: (key) => ({ PLEUM_API_KEY: key }),
64
- args: () => [],
79
+ args: (m) => {
80
+ const isObj = m && typeof m === "object";
81
+ const effort = isObj ? m.effort : null;
82
+ return effort ? ["-c", `model_reasoning_effort="${effort}"`] : [];
83
+ },
84
+ },
85
+ agy: {
86
+ label: "Antigravity CLI",
87
+ bin: "agy",
88
+ type: "agy-settings",
89
+ install: "https://antigravity.google/cli/install.sh",
90
+ note: "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. Routes any coding model via PleumRouter.",
91
+ attribution: {
92
+ title: "Antigravity CLI",
93
+ referer: "https://www.antigravity.google/docs/cli/install",
94
+ },
95
+ env: (key) => ({
96
+ GOOGLE_GEMINI_BASE_URL: ROOT,
97
+ GEMINI_API_KEY: key,
98
+ GOOGLE_API_KEY: key,
99
+ }),
100
+ args: (m) => {
101
+ const isObj = m && typeof m === "object";
102
+ const model = (isObj ? m.model : m) || DEFAULT_MODEL;
103
+ const effort = isObj ? m.effort : null;
104
+ const res = ["--model", model];
105
+ if (effort) res.push("--effort", effort);
106
+ return res;
107
+ },
108
+ },
109
+ opencode: {
110
+ label: "opencode",
111
+ bin: "opencode",
112
+ type: "env",
113
+ install: "npm i -g opencode-ai@latest",
114
+ // opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
115
+ // 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
116
+ note: "Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched.",
117
+ attribution: {
118
+ title: "opencode",
119
+ referer: "https://opencode.ai",
120
+ },
121
+ env: (key, model) => {
122
+ const active = model || DEFAULT_MODEL;
123
+ const models = Object.fromEntries(CORE_MODELS.map((id) => [id, {}]));
124
+ models[active] = {};
125
+ return {
126
+ PLEUM_API_KEY: key,
127
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
128
+ provider: {
129
+ pleum: {
130
+ npm: "@ai-sdk/openai-compatible",
131
+ name: "PleumRouter",
132
+ options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
133
+ models,
134
+ },
135
+ },
136
+ }),
137
+ };
138
+ },
139
+ args: (m) => ["--model", `pleum/${m || DEFAULT_MODEL}`],
65
140
  },
66
141
  aider: {
67
142
  label: "Aider",
@@ -101,38 +176,6 @@ export const RECIPES = {
101
176
  env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
102
177
  args: () => [],
103
178
  },
104
- opencode: {
105
- label: "opencode",
106
- bin: "opencode",
107
- type: "env",
108
- install: "npm i -g opencode-ai@latest",
109
- // opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
110
- // 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
111
- note: "Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched.",
112
- attribution: {
113
- title: "opencode",
114
- referer: "https://opencode.ai",
115
- },
116
- env: (key, model) => {
117
- const active = model || DEFAULT_MODEL;
118
- const models = Object.fromEntries(CORE_MODELS.map((id) => [id, {}]));
119
- models[active] = {};
120
- return {
121
- PLEUM_API_KEY: key,
122
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
123
- provider: {
124
- pleum: {
125
- npm: "@ai-sdk/openai-compatible",
126
- name: "PleumRouter",
127
- options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
128
- models,
129
- },
130
- },
131
- }),
132
- };
133
- },
134
- args: (m) => ["--model", `pleum/${m || DEFAULT_MODEL}`],
135
- },
136
179
  omp: {
137
180
  label: "omp (Oh My Pi)",
138
181
  bin: "omp",
@@ -211,23 +254,6 @@ export const RECIPES = {
211
254
  }),
212
255
  args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("google/") ? (m || DEFAULT_MODEL) : `google/${m || DEFAULT_MODEL}`],
213
256
  },
214
- agy: {
215
- label: "Antigravity CLI",
216
- bin: "agy",
217
- type: "agy-settings",
218
- install: "https://antigravity.google/cli/install.sh",
219
- note: "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. GEMINI_API_KEY alone is ignored. Gemini-family models only.",
220
- attribution: {
221
- title: "Antigravity CLI",
222
- referer: "https://www.antigravity.google/docs/cli/install",
223
- },
224
- env: (key) => ({
225
- GOOGLE_GEMINI_BASE_URL: ROOT,
226
- GEMINI_API_KEY: key,
227
- GOOGLE_API_KEY: key,
228
- }),
229
- args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("google/") ? (m || DEFAULT_MODEL) : `google/${m || DEFAULT_MODEL}`],
230
- },
231
257
  pi: {
232
258
  label: "Pi",
233
259
  bin: "pi",
@@ -376,12 +402,15 @@ export function tomlBasicString(value) {
376
402
  return `"${escaped}"`;
377
403
  }
378
404
 
379
- export function codexToml(model) {
380
- const m = model || DEFAULT_MODEL;
405
+ export function codexToml(model, effort) {
406
+ const isObj = model && typeof model === "object";
407
+ const m = (isObj ? model.model : model) || DEFAULT_MODEL;
408
+ const eff = (isObj ? model.effort : effort) || null;
409
+ const effortLine = eff ? `model_reasoning_effort = ${tomlBasicString(eff)}\n` : "";
381
410
  return `# Generated by pleum CLI. Isolated CODEX_HOME — your ~/.codex is untouched.
382
411
  model = ${tomlBasicString(m)}
383
412
  model_provider = "pleum"
384
-
413
+ ${effortLine}
385
414
  [model_providers.pleum]
386
415
  name = "PleumRouter"
387
416
  base_url = ${tomlBasicString(V1)}
@@ -399,7 +428,9 @@ export function mergeAgySettings(existingJson, model) {
399
428
  throw new Error("agy settings.json must be a JSON object");
400
429
  }
401
430
  }
402
- return { ...obj, modelProvider: "gemini", model: model || DEFAULT_MODEL };
431
+ const isObj = model && typeof model === "object";
432
+ const m = (isObj ? model.model : model) || DEFAULT_MODEL;
433
+ return { ...obj, modelProvider: "gemini", model: m };
403
434
  }
404
435
 
405
436
  export function piModelsJson(model) {
package/src/settings.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  import { readFileSync } from "node:fs";
9
9
  import { style, sanitizeTerminalText } from "./ansi.mjs";
10
10
  import { select, confirm } from "./interactive.mjs";
11
- import { readConfig, writeConfig, isLoggedIn, getActiveContext } from "./config.mjs";
11
+ import { readConfig, writeConfig, isLoggedIn, getActiveContext, isAutoUpdateEnabled, setAutoUpdateEnabled } from "./config.mjs";
12
12
  import { getLocale, LANGS, t } from "./i18n.mjs";
13
13
  import { RECIPES } from "./recipes.mjs";
14
14
  import { fetchModels, matchModels, formatModelLabel } from "./models.mjs";
@@ -18,12 +18,25 @@ import { completion } from "./completion.mjs";
18
18
  import { lang } from "./lang.mjs";
19
19
  import { switchCreditWorkspace } from "./balance.mjs";
20
20
  import { runList, saveModel } from "./menu.mjs";
21
+ import { checkLatestVersion, isNewerVersion, runUpdateCommand } from "./update.mjs";
21
22
 
22
23
  // Settings 서브메뉴 항목(순수 함수 — 테스트 대상).
23
- export function buildSettingsItems({ lang, loggedIn, keyPrefix, version, activeContext = { type: "personal" } }) {
24
+ export function buildSettingsItems({
25
+ lang,
26
+ loggedIn,
27
+ keyPrefix,
28
+ version,
29
+ activeContext = { type: "personal" },
30
+ autoUpdate = true,
31
+ latestVersion = null,
32
+ }) {
24
33
  const contextDesc = activeContext?.type === "team" && activeContext.org_name
25
34
  ? `${t("Team")}: ${activeContext.org_name}`
26
35
  : t("Personal Credit");
36
+ const updateDesc = latestVersion && isNewerVersion(version, latestVersion)
37
+ ? `${t("Current version: {version}", { version })} → v${latestVersion} (${t("Update available")})`
38
+ : t("Current version: {version}", { version });
39
+
27
40
  return [
28
41
  { id: "settings:lang", title: t("Language"), desc: `${t("current")}: ${LANGS[lang] ?? lang}` },
29
42
  { id: "settings:models", title: t("Default models"), desc: t("Set the default model used per agent") },
@@ -38,7 +51,12 @@ export function buildSettingsItems({ lang, loggedIn, keyPrefix, version, activeC
38
51
  : `${t("Logged in")} (${t("key")} ${keyPrefix}…)`
39
52
  : t("Not logged in"),
40
53
  },
41
- { id: "settings:update", title: t("Update CLI"), desc: t("Current version: {version}", { version }) },
54
+ {
55
+ id: "settings:auto_update",
56
+ title: t("Auto update"),
57
+ desc: autoUpdate ? t("Enabled (updates automatically on start)") : t("Disabled"),
58
+ },
59
+ { id: "settings:update", title: t("Update CLI"), desc: updateDesc },
42
60
  { id: "settings:completion", title: t("Shell completion"), desc: t("Print a zsh/bash completion script") },
43
61
  ];
44
62
  }
@@ -73,12 +91,18 @@ function clearSavedModel(tool) {
73
91
 
74
92
  async function settingsSelect() {
75
93
  const cfg = readConfig();
94
+ let updateInfo = null;
95
+ try {
96
+ updateInfo = await checkLatestVersion();
97
+ } catch {}
76
98
  const items = buildSettingsItems({
77
99
  lang: getLocale(),
78
100
  loggedIn: isLoggedIn(cfg),
79
101
  keyPrefix: cfg?.access_token ? "oauth" : cfg?.key ? cfg.key.slice(0, 8) : null,
80
102
  version: readVersion(),
81
103
  activeContext: getActiveContext(cfg),
104
+ autoUpdate: isAutoUpdateEnabled(cfg),
105
+ latestVersion: updateInfo?.latestVersion,
82
106
  });
83
107
  return runList(items, {
84
108
  header: style.bold(style.plum(t("Settings"))),
@@ -164,12 +188,14 @@ export async function runSettings() {
164
188
  case "settings:account":
165
189
  await runAccount();
166
190
  continue;
191
+ case "settings:auto_update": {
192
+ const next = !isAutoUpdateEnabled();
193
+ setAutoUpdateEnabled(next);
194
+ continue;
195
+ }
167
196
  case "settings:update":
168
- // upgrade()는 자식(npm install) 종료 시 process.exit()을 호출한다. 여기서 continue해
169
- // 메뉴를 다시 그리면 raw-mode 화면과 npm 출력이 뒤섞이다 프로세스가 갑자기 죽으므로,
170
- // mainMenu()의 chat 케이스처럼 즉시 루프를 벗어난다.
171
- console.log(t("Reinstalling — pleum will exit when finished. Run `pleum` again afterward."));
172
- upgrade();
197
+ console.log(t("Updating pleumrouter CLI..."));
198
+ await runUpdateCommand([], { exit: false });
173
199
  return "quit";
174
200
  case "settings:completion":
175
201
  await runCompletion();
package/src/update.mjs ADDED
@@ -0,0 +1,157 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { t } from "./i18n.mjs";
6
+
7
+ const REGISTRY_URL = "https://registry.npmjs.org/pleumrouter/latest";
8
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1시간 캐시
9
+
10
+ export function readCliVersion() {
11
+ try {
12
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
+ return pkg.version;
14
+ } catch {
15
+ return "0.0.0";
16
+ }
17
+ }
18
+
19
+ export function parseSemver(v) {
20
+ if (!v) return [0, 0, 0];
21
+ const clean = String(v).trim().replace(/^v/, "");
22
+ const parts = clean.split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
23
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
24
+ }
25
+
26
+ export function isNewerVersion(current, latest) {
27
+ const [cMaj, cMin, cPat] = parseSemver(current);
28
+ const [lMaj, lMin, lPat] = parseSemver(latest);
29
+ if (lMaj !== cMaj) return lMaj > cMaj;
30
+ if (lMin !== cMin) return lMin > cMin;
31
+ return lPat > cPat;
32
+ }
33
+
34
+ export function getUpdateCachePath() {
35
+ return join(homedir(), ".pleum", "update-cache.json");
36
+ }
37
+
38
+ export function readUpdateCache(cachePath = getUpdateCachePath()) {
39
+ try {
40
+ if (!existsSync(cachePath)) return null;
41
+ return JSON.parse(readFileSync(cachePath, "utf8"));
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function writeUpdateCache(latestVersion, cachePath = getUpdateCachePath()) {
48
+ try {
49
+ const dir = join(homedir(), ".pleum");
50
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ writeFileSync(
52
+ cachePath,
53
+ JSON.stringify({ last_check: Date.now(), latest_version: latestVersion }, null, 2),
54
+ { mode: 0o600 },
55
+ );
56
+ } catch {}
57
+ }
58
+
59
+ export async function fetchLatestVersionFromRegistry({ timeoutMs = 2000, fetchFn = globalThis.fetch } = {}) {
60
+ try {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
63
+ const res = await fetchFn(REGISTRY_URL, {
64
+ signal: controller.signal,
65
+ headers: { Accept: "application/json" },
66
+ });
67
+ clearTimeout(timer);
68
+ if (!res.ok) return null;
69
+ const data = await res.json();
70
+ return data?.version ?? null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ export async function checkLatestVersion({
77
+ force = false,
78
+ timeoutMs = 2000,
79
+ currentVersion = null,
80
+ cachePath = getUpdateCachePath(),
81
+ fetchFn = globalThis.fetch,
82
+ } = {}) {
83
+ const cur = currentVersion || readCliVersion();
84
+ const cached = readUpdateCache(cachePath);
85
+
86
+ if (!force && cached?.latest_version && typeof cached.last_check === "number") {
87
+ if (Date.now() - cached.last_check < CHECK_INTERVAL_MS) {
88
+ return {
89
+ currentVersion: cur,
90
+ latestVersion: cached.latest_version,
91
+ isOutdated: isNewerVersion(cur, cached.latest_version),
92
+ };
93
+ }
94
+ }
95
+
96
+ const remote = await fetchLatestVersionFromRegistry({ timeoutMs, fetchFn });
97
+ if (remote) {
98
+ writeUpdateCache(remote, cachePath);
99
+ return {
100
+ currentVersion: cur,
101
+ latestVersion: remote,
102
+ isOutdated: isNewerVersion(cur, remote),
103
+ };
104
+ }
105
+
106
+ // 네트워크 실패 시 기존 캐시가 있으면 재활용, 없으면 현재 버전으로 안전 폴백
107
+ const fallbackLatest = cached?.latest_version || cur;
108
+ return {
109
+ currentVersion: cur,
110
+ latestVersion: fallbackLatest,
111
+ isOutdated: isNewerVersion(cur, fallbackLatest),
112
+ };
113
+ }
114
+
115
+ export function performUpdate({ bin = null } = {}) {
116
+ const npmCmd = bin || (process.platform === "win32" ? "npm.cmd" : "npm");
117
+ return new Promise((resolve) => {
118
+ const child = spawn(npmCmd, ["install", "-g", "pleumrouter@latest"], {
119
+ stdio: "inherit",
120
+ shell: false,
121
+ });
122
+ child.on("error", (e) => {
123
+ console.error(`✗ ${e.message}`);
124
+ resolve(1);
125
+ });
126
+ child.on("exit", (code) => resolve(code ?? 0));
127
+ });
128
+ }
129
+
130
+ export async function runUpdateCommand(args = [], { exit = true } = {}) {
131
+ const force = args.includes("--force") || args.includes("-f");
132
+ const cur = readCliVersion();
133
+
134
+ if (force) {
135
+ console.log(`▶ ${t("Updating pleumrouter (force reinstall)...")}\n`);
136
+ const code = await performUpdate();
137
+ if (exit) process.exit(code);
138
+ return code;
139
+ }
140
+
141
+ const { latestVersion, isOutdated } = await checkLatestVersion({ force: true });
142
+
143
+ if (!isOutdated) {
144
+ console.log(`✓ ${t("Already using the latest version of pleumrouter (v{version}).", { version: cur })}`);
145
+ console.log(t("Use --force (-f) to reinstall anyway."));
146
+ if (exit) process.exit(0);
147
+ return 0;
148
+ }
149
+
150
+ console.log(`▶ ${t("Updating pleumrouter from v{from} to v{to}...", { from: cur, to: latestVersion })}\n`);
151
+ const code = await performUpdate();
152
+ if (code === 0) {
153
+ console.log(`\n✓ ${t("Update completed! Please run `pleum` again to use the new version.")}`);
154
+ }
155
+ if (exit) process.exit(code);
156
+ return code;
157
+ }
package/src/upgrade.mjs CHANGED
@@ -1,16 +1,10 @@
1
- import { spawn } from "node:child_process";
1
+ import { performUpdate, runUpdateCommand } from "./update.mjs";
2
2
 
3
- // 최신 버전으로 전역 재설치. brew tap 보류 상태라 npm 경로([[project_pleum_cli]]).
4
- export function upgrade() {
5
- console.log("▶ npm install -g pleumrouter@latest\n");
6
- const bin = process.platform === "win32" ? "npm.cmd" : "npm";
7
- const child = spawn(bin, ["install", "-g", "pleumrouter@latest"], {
8
- stdio: "inherit",
9
- shell: false,
10
- });
11
- child.on("error", (e) => {
12
- console.error(`✗ ${e.message}`);
13
- process.exit(1);
14
- });
15
- child.on("exit", (code) => process.exit(code ?? 0));
3
+ export { performUpdate, runUpdateCommand };
4
+
5
+ // 최신 버전으로 전역 재설치. 하위 호환을 위해 유지하며 process.exit() 호출 규약을 보존한다.
6
+ export async function upgrade() {
7
+ const code = await runUpdateCommand(["--force"], { exit: false });
8
+ process.exit(code ?? 0);
16
9
  }
10
+