pleumrouter 0.5.24 → 0.5.26

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/bin/pleum.mjs CHANGED
@@ -11,7 +11,7 @@ import { top } from "../src/top.mjs";
11
11
  import { health } from "../src/health.mjs";
12
12
  import { completion } from "../src/completion.mjs";
13
13
  import { upgrade } from "../src/upgrade.mjs";
14
- import { mainMenu } from "../src/menu.mjs";
14
+ import { mainMenu, runAgentMenu } from "../src/menu.mjs";
15
15
  import { lang } from "../src/lang.mjs";
16
16
  import { RECIPES } from "../src/recipes.mjs";
17
17
  import { t } from "../src/i18n.mjs";
@@ -98,6 +98,11 @@ try {
98
98
  case "launch": {
99
99
  const tool = rest[0];
100
100
  if (!tool) {
101
+ const io = process.stdin;
102
+ if (io.isTTY && process.stdout.isTTY && typeof io.setRawMode === "function") {
103
+ await runAgentMenu({ loop: false });
104
+ break;
105
+ }
101
106
  launchHelp();
102
107
  break;
103
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.24",
3
+ "version": "0.5.26",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -62,15 +62,9 @@ export function clearCredentials(existing) {
62
62
  }
63
63
 
64
64
  /**
65
- * 사용 가능한 세션 보유 여부. 레거시 `key`(plm_)만 있는 상태는 requireAuth()가 거부하므로
66
- * (아래 requireAuth 참조) 미로그인으로 취급한다 — 그래야 런처가 자동으로 재로그인을 태운다.
65
+ * JWT payload 파싱(서명 검증 없는 디코드) — iat, exp 등의 클레임 추출용.
67
66
  */
68
- export function isLoggedIn(cfg = readConfig()) {
69
- return Boolean(cfg?.access_token);
70
- }
71
-
72
- /** JWT payload `exp`(epoch sec) — 서명 검증 없이 refresh 트리거 힌트. */
73
- export function readJwtExp(token) {
67
+ export function readJwtPayload(token) {
74
68
  if (typeof token !== "string") return null;
75
69
  const parts = token.split(".");
76
70
  const segment = parts[1];
@@ -80,14 +74,32 @@ export function readJwtExp(token) {
80
74
  const pad = b64.length % 4;
81
75
  if (pad) b64 += "=".repeat(4 - pad);
82
76
  const json = Buffer.from(b64, "base64").toString("utf8");
83
- const payload = JSON.parse(json);
84
- return typeof payload.exp === "number" ? payload.exp : null;
77
+ return JSON.parse(json);
85
78
  } catch {
86
79
  return null;
87
80
  }
88
81
  }
89
82
 
90
- /** access가 곧 만료(기본 60s 여유)면 true. */
83
+ /** JWT payload `exp`(epoch sec) */
84
+ export function readJwtExp(token) {
85
+ const payload = readJwtPayload(token);
86
+ return typeof payload?.exp === "number" ? payload.exp : null;
87
+ }
88
+
89
+ /** JWT payload `iat`(epoch sec) */
90
+ export function readJwtIat(token) {
91
+ const payload = readJwtPayload(token);
92
+ return typeof payload?.iat === "number" ? payload.iat : null;
93
+ }
94
+
95
+ /** 토큰 만료 여부 판정 (skewSeconds 여유 포함, unparseable은 false 반환) */
96
+ export function isTokenExpired(token, skewSeconds = 0) {
97
+ const exp = readJwtExp(token);
98
+ if (exp == null) return false;
99
+ return exp * 1000 <= Date.now() + skewSeconds * 1000;
100
+ }
101
+
102
+ /** access가 곧 만료(기본 60s 여유)면 true. unparseable은 만료로 간주. */
91
103
  export function accessTokenIsStale(accessToken, skewSeconds = 60) {
92
104
  const exp = readJwtExp(accessToken);
93
105
  if (exp == null) return true;
@@ -95,29 +107,59 @@ export function accessTokenIsStale(accessToken, skewSeconds = 60) {
95
107
  }
96
108
 
97
109
  /**
98
- * 유효한 access_token을 반환. stale이면 refresh. 레거시 `key`(plm_)만 있으면 재로그인 안내.
110
+ * 사용 가능한 세션 보유 여부.
111
+ * 1) access_token 또는 refresh_token이 없으면 미로그인.
112
+ * 2) refresh_token이 이미 7일 만료되었으면 미로그인.
113
+ * 3) 레거시 `key`(plm_)만 있는 상태는 requireAuth()가 거부하므로 미로그인.
114
+ */
115
+ export function isLoggedIn(cfg = readConfig()) {
116
+ if (!cfg?.access_token || !cfg?.refresh_token) return false;
117
+ if (isTokenExpired(cfg.refresh_token)) return false;
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * 7일 슬라이딩 세션 갱신 (1시간 스로틀링).
123
+ * CLI 접속 시 마지막 갱신(iat)으로부터 minIntervalSeconds(기본 3600초=1시간) 이상 지났거나
124
+ * access_token이 곧 만료(stale)되면 /v1/auth/refresh를 호출해 만료일을 현재+7일로 연장한다.
125
+ */
126
+ export async function touchSession(cfg = readConfig(), { minIntervalSeconds = 3600 } = {}) {
127
+ if (!isLoggedIn(cfg)) return null;
128
+ const iat = readJwtIat(cfg.refresh_token) ?? readJwtIat(cfg.access_token);
129
+ const ageSeconds = iat ? Math.max(0, Math.floor(Date.now() / 1000 - iat)) : Infinity;
130
+ const isStale = accessTokenIsStale(cfg.access_token, 300);
131
+ if (!isStale && ageSeconds < minIntervalSeconds) {
132
+ return cfg.access_token;
133
+ }
134
+ return await refreshAccessToken(cfg);
135
+ }
136
+
137
+ /**
138
+ * 유효한 access_token을 반환. stale이거나 1시간 경과 시 refresh하여 슬라이딩 윈도우 유지.
139
+ * 레거시 `key`(plm_)만 있으면 재로그인 안내.
99
140
  */
100
141
  export async function requireAuth() {
101
- const cfg = readConfig();
142
+ let cfg = readConfig();
102
143
  if (cfg?.key && !cfg?.access_token) {
103
144
  console.error(
104
145
  t("Legacy API key login is no longer supported. Run:\n pleum login"),
105
146
  );
106
147
  process.exit(1);
107
148
  }
108
- if (!cfg?.access_token || !cfg?.refresh_token) {
149
+ if (!isLoggedIn(cfg)) {
109
150
  console.error(t("Login required. Run this first:\n pleum login"));
110
151
  process.exit(1);
111
152
  }
112
- if (!accessTokenIsStale(cfg.access_token)) {
153
+ const token = await touchSession(cfg);
154
+ if (token) return token;
155
+
156
+ cfg = readConfig();
157
+ if (cfg?.access_token && !accessTokenIsStale(cfg.access_token, 0)) {
113
158
  return cfg.access_token;
114
159
  }
115
- const refreshed = await refreshAccessToken(cfg);
116
- if (!refreshed) {
117
- console.error(t("Session expired. Run:\n pleum login"));
118
- process.exit(1);
119
- }
120
- return refreshed;
160
+
161
+ console.error(t("Session expired. Run:\n pleum login"));
162
+ process.exit(1);
121
163
  }
122
164
 
123
165
  export async function refreshAccessToken(cfg = readConfig()) {
@@ -129,7 +171,12 @@ export async function refreshAccessToken(cfg = readConfig()) {
129
171
  headers: { "Content-Type": "application/json" },
130
172
  body: JSON.stringify({ refresh_token: cfg.refresh_token }),
131
173
  });
132
- if (!res.ok) return null;
174
+ if (!res.ok) {
175
+ if (res.status === 401 || res.status === 403) {
176
+ writeConfig(clearCredentials(cfg));
177
+ }
178
+ return null;
179
+ }
133
180
  const data = await res.json();
134
181
  if (!data.access_token || !data.refresh_token) return null;
135
182
  const exp = readJwtExp(data.access_token);
package/src/health.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // `pleum health` — 게이트웨이 연결성 + 로그인 상태 한눈에.
2
2
  // 셀프호스트/스테이징(PLEUM_BASE_URL) 점검·고객지원 트리아지용. 공개 /v1/health 재사용.
3
3
 
4
- import { ROOT, V1, readConfig } from "./config.mjs";
4
+ import { ROOT, V1, readConfig, isLoggedIn } from "./config.mjs";
5
5
  import { style, divider } from "./ansi.mjs";
6
6
  import { t } from "./i18n.mjs";
7
7
 
@@ -39,7 +39,7 @@ export function renderHealth({ root, health, error, ms, keyPrefix }, styler = st
39
39
 
40
40
  export async function health() {
41
41
  const cfg = readConfig();
42
- const keyPrefix = cfg?.access_token
42
+ const keyPrefix = isLoggedIn(cfg)
43
43
  ? "oauth"
44
44
  : cfg?.key
45
45
  ? cfg.key.slice(0, 8)
@@ -100,6 +100,7 @@ export async function select(items, {
100
100
  formatItem = String,
101
101
  matches = (all, q) => (q ? all.filter((it) => String(formatItem(it)).toLowerCase().includes(q.toLowerCase())) : all),
102
102
  limit = 10,
103
+ initialCursor = 0,
103
104
  input = process.stdin,
104
105
  output = process.stdout,
105
106
  } = {}) {
@@ -107,7 +108,7 @@ export async function select(items, {
107
108
 
108
109
  return new Promise((resolve) => {
109
110
  let query = "";
110
- let cursor = 0;
111
+ let cursor = Math.max(0, Math.min(initialCursor, Math.max(0, (items?.length ?? 1) - 1)));
111
112
  let lastRows = 0;
112
113
 
113
114
  const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
package/src/launch.mjs CHANGED
@@ -126,7 +126,7 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
126
126
  if (r.type === "crush-home") {
127
127
  env.CRUSH_GLOBAL_CONFIG = writeIsolatedHome("crush-home", "crush.json", crushJson(model));
128
128
  }
129
- if (r.type === "agy-settings") ensureAgySettings();
129
+ if (r.type === "agy-settings") ensureAgySettings(model);
130
130
 
131
131
  const args = [...r.args(model), ...passthrough];
132
132
  if (r.note) console.log(`ℹ ${t(r.note)}`);
@@ -171,12 +171,12 @@ function writeIsolatedHome(name, filename, contents) {
171
171
  return home;
172
172
  }
173
173
 
174
- function ensureAgySettings() {
174
+ function ensureAgySettings(model) {
175
175
  const dir = join(homedir(), ".gemini", "antigravity-cli");
176
176
  const path = join(dir, "settings.json");
177
177
  mkdirSync(dir, { recursive: true, mode: 0o700 });
178
178
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
179
- writeFileSync(path, `${JSON.stringify(mergeAgySettings(existing), null, 2)}\n`, { mode: 0o600 });
179
+ writeFileSync(path, `${JSON.stringify(mergeAgySettings(existing, model), null, 2)}\n`, { mode: 0o600 });
180
180
  }
181
181
 
182
182
  function printManual(tool, key) {
package/src/menu.mjs CHANGED
@@ -13,8 +13,8 @@ import { spawn } from "node:child_process";
13
13
  import { createInterface } from "node:readline";
14
14
  import { style, displayWidth, terminalWidth, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
15
15
  import { keyFeeder, select } from "./interactive.mjs";
16
- import { readConfig, writeConfig, isLoggedIn } from "./config.mjs";
17
- import { RECIPES, MANUAL } from "./recipes.mjs";
16
+ import { readConfig, writeConfig, isLoggedIn, touchSession } from "./config.mjs";
17
+ import { RECIPES, MANUAL, DEFAULT_MODEL } from "./recipes.mjs";
18
18
  import { onPath, launch } from "./launch.mjs";
19
19
  import { fetchModels, matchModels, listModels, formatModelLabel } from "./models.mjs";
20
20
  import { run } from "./run.mjs";
@@ -120,7 +120,7 @@ function saveLastSelection(id) {
120
120
  writeConfig(cfg);
121
121
  }
122
122
 
123
- async function pickModelFor(item) {
123
+ export async function pickModelFor(item, defaultModel) {
124
124
  let models;
125
125
  try {
126
126
  models = await fetchModels();
@@ -128,11 +128,14 @@ async function pickModelFor(item) {
128
128
  console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
129
129
  return undefined;
130
130
  }
131
+ const targetId = defaultModel || DEFAULT_MODEL;
132
+ const initialIndex = models.findIndex((x) => x.id === targetId);
131
133
  const m = await select(models, {
132
134
  message: `${item.title} — ${t("select a model")}`,
133
135
  formatItem: (x) => formatModelLabel(x),
134
136
  matches: matchModels,
135
- limit: 12,
137
+ limit: 10,
138
+ initialCursor: initialIndex >= 0 ? initialIndex : 0,
136
139
  });
137
140
  return m?.id;
138
141
  }
@@ -229,7 +232,8 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
229
232
  // 선택되면 ollama처럼 즉시 실행으로 이어가고, 취소면 메뉴 복귀.
230
233
  const configure = async (it) => {
231
234
  suspend();
232
- const picked = await pickModelFor(it);
235
+ const currentModel = it.tool ? (readConfig()?.launch_models?.[it.tool] || DEFAULT_MODEL) : undefined;
236
+ const picked = await pickModelFor(it, currentModel);
233
237
  if (picked) {
234
238
  input.pause();
235
239
  resolve({ item: it, model: picked });
@@ -292,6 +296,48 @@ async function agentSelect() {
292
296
  });
293
297
  }
294
298
 
299
+ // 에이전트 실행 메뉴 — bare pleum의 "에이전트 실행"과 pleum launch (인자 없음)가 공유.
300
+ // 에이전트를 고르면 곧바로 핵심 10개 모델 피커를 띄워 기본 모델(deepseek-v4.1-flash) 또는
301
+ // 선택한 우리 모델로 코딩 에이전트를 실행한다.
302
+ export async function runAgentMenu({ loop = false } = {}) {
303
+ const io = process.stdin;
304
+ if (!io.isTTY || !process.stdout.isTTY || typeof io.setRawMode !== "function") return false;
305
+ if (!isLoggedIn()) await login();
306
+ else await touchSession().catch(() => {});
307
+
308
+ for (;;) {
309
+ const agentPicked = await agentSelect();
310
+ if (agentPicked === "quit") return "quit";
311
+ if (!agentPicked) return null;
312
+
313
+ const { item: agentItem } = agentPicked;
314
+ if (agentItem.manual) {
315
+ await launch(agentItem.tool, undefined, [], { exit: false });
316
+ if (!loop) return true;
317
+ continue;
318
+ }
319
+
320
+ const r = RECIPES[agentItem.tool];
321
+ if (!onPath(r.bin) && !(await ensureInstalled(r))) {
322
+ if (!loop) return false;
323
+ continue;
324
+ }
325
+
326
+ const agentSaved = readConfig()?.launch_models ?? {};
327
+ let chosenModel = agentPicked.model;
328
+ if (!chosenModel) {
329
+ const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
330
+ chosenModel = await pickModelFor(agentItem, defaultForTool);
331
+ if (!chosenModel) continue; // Esc 취소 시 에이전트 목록으로 복귀
332
+ }
333
+
334
+ saveModel(agentItem.tool, chosenModel);
335
+ await launch(agentItem.tool, chosenModel, [], { exit: false });
336
+ console.log();
337
+ if (!loop) return true;
338
+ }
339
+ }
340
+
295
341
  // bare `pleum` 진입점. 비TTY면 false(호출자가 HELP 출력), 그 외 true.
296
342
  // ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·ctrl+c 종료 제외).
297
343
  export async function mainMenu() {
@@ -303,6 +349,7 @@ export async function mainMenu() {
303
349
  // login()의 실패(취소·2분 타임아웃·state 불일치)는 그대로 던져 bin/pleum.mjs의
304
350
  // 최상위 catch가 `✗ <메시지>` + exit 1로 처리하게 둔다(메뉴를 띄우지 않는다).
305
351
  if (!isLoggedIn()) await login();
352
+ else await touchSession().catch(() => {});
306
353
 
307
354
  for (;;) {
308
355
  const picked = await menuSelect();
@@ -341,19 +388,9 @@ export async function mainMenu() {
341
388
  continue; // esc/q — 메인 메뉴로 복귀
342
389
  }
343
390
  case "agents": {
344
- const agentPicked = await agentSelect();
345
- if (agentPicked === "quit") return true; // 서브메뉴에서 ctrl+c → 전체 종료
346
- if (!agentPicked) continue; // esc/q — 메인 메뉴로 복귀
347
- const { item: agentItem, model: agentModel } = agentPicked;
348
- if (agentModel && !agentItem.manual) saveModel(agentItem.tool, agentModel);
349
- if (!agentItem.manual) {
350
- const r = RECIPES[agentItem.tool];
351
- if (!onPath(r.bin) && !(await ensureInstalled(r))) continue;
352
- }
353
- const agentSaved = readConfig()?.launch_models ?? {};
354
- await launch(agentItem.tool, agentModel ?? agentSaved[agentItem.tool], [], { exit: false });
355
- console.log(); // 에이전트 출력과 다음 메뉴 사이 여백
356
- continue; // ollama처럼 에이전트 종료 후 메뉴 복귀
391
+ const r = await runAgentMenu({ loop: true });
392
+ if (r === "quit") return true;
393
+ continue;
357
394
  }
358
395
  }
359
396
  }
package/src/recipes.mjs CHANGED
@@ -1,5 +1,20 @@
1
1
  import { ROOT, V1 } from "./config.mjs";
2
2
 
3
+ export const DEFAULT_MODEL = "deepseek-ai/DeepSeek-V4.1-Flash";
4
+
5
+ export const CORE_MODELS = [
6
+ "deepseek-ai/DeepSeek-V4.1-Flash",
7
+ "claude-sonnet-5",
8
+ "claude-opus-5-5",
9
+ "gpt-6-astra",
10
+ "claude-fable-5-1",
11
+ "gemini-3.8-flash",
12
+ "glm-5.3-flash",
13
+ "kimi-k3",
14
+ "gpt-6-sol",
15
+ "gpt-6-luna",
16
+ ];
17
+
3
18
  // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/{codex,claude-code,...} + 에이전트별 공식 문서.
4
19
  // type "env" → 환경변수만 세팅하고 바로 실행.
5
20
  // type "codex-home" → 격리 CODEX_HOME에 config.toml (사용자 ~/.codex 보존).
@@ -34,7 +49,7 @@ export const RECIPES = {
34
49
  // /model 이 GET {ANTHROPIC_BASE_URL}/v1/models 를 치게 한다. 기본값은 꺼져 있다.
35
50
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
36
51
  }),
37
- args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
52
+ args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("anthropic/") ? (m || DEFAULT_MODEL) : `anthropic/${m || DEFAULT_MODEL}`],
38
53
  },
39
54
  codex: {
40
55
  label: "Codex CLI",
@@ -58,7 +73,7 @@ export const RECIPES = {
58
73
  referer: "https://aider.chat",
59
74
  },
60
75
  env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
61
- args: (m) => (m ? ["--model", `openai/${m}`] : []),
76
+ args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("openai/") ? (m || DEFAULT_MODEL) : `openai/${m || DEFAULT_MODEL}`],
62
77
  },
63
78
  goose: {
64
79
  label: "Goose",
@@ -98,20 +113,25 @@ export const RECIPES = {
98
113
  title: "opencode",
99
114
  referer: "https://opencode.ai",
100
115
  },
101
- env: (key, model) => ({
102
- PLEUM_API_KEY: key,
103
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
104
- provider: {
105
- pleum: {
106
- npm: "@ai-sdk/openai-compatible",
107
- name: "PleumRouter",
108
- options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
109
- models: { [model || "gpt-5.4-mini"]: {} },
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
+ },
110
130
  },
111
- },
112
- }),
113
- }),
114
- args: (m) => ["--model", `pleum/${m || "gpt-5.4-mini"}`],
131
+ }),
132
+ };
133
+ },
134
+ args: (m) => ["--model", `pleum/${m || DEFAULT_MODEL}`],
115
135
  },
116
136
  omp: {
117
137
  label: "omp (Oh My Pi)",
@@ -126,7 +146,7 @@ export const RECIPES = {
126
146
  referer: "https://omp.sh",
127
147
  },
128
148
  env: (key) => ({ ANTHROPIC_BASE_URL: ROOT, ANTHROPIC_API_KEY: key }),
129
- args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
149
+ args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("anthropic/") ? (m || DEFAULT_MODEL) : `anthropic/${m || DEFAULT_MODEL}`],
130
150
  },
131
151
  hermes: {
132
152
  label: "Hermes Agent",
@@ -150,7 +170,7 @@ export const RECIPES = {
150
170
  title: "Qwen Code",
151
171
  referer: "https://github.com/QwenLM/qwen-code",
152
172
  },
153
- env: (key, model) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key, OPENAI_MODEL: model || "gpt-5.4-mini" }),
173
+ env: (key, model) => ({ OPENAI_BASE_URL: V1, OPENAI_API_KEY: key, OPENAI_MODEL: model || DEFAULT_MODEL }),
154
174
  args: () => [],
155
175
  },
156
176
  copilot: {
@@ -167,7 +187,7 @@ export const RECIPES = {
167
187
  COPILOT_PROVIDER_BASE_URL: V1,
168
188
  COPILOT_PROVIDER_TYPE: "openai",
169
189
  COPILOT_PROVIDER_API_KEY: key,
170
- COPILOT_MODEL: model || "gpt-5.4-mini",
190
+ COPILOT_MODEL: model || DEFAULT_MODEL,
171
191
  }),
172
192
  args: () => [],
173
193
  },
@@ -189,7 +209,7 @@ export const RECIPES = {
189
209
  GEMINI_API_KEY: key,
190
210
  GOOGLE_API_KEY: key,
191
211
  }),
192
- args: (m) => (m ? ["--model", `google/${m}`] : []),
212
+ args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("google/") ? (m || DEFAULT_MODEL) : `google/${m || DEFAULT_MODEL}`],
193
213
  },
194
214
  agy: {
195
215
  label: "Antigravity CLI",
@@ -206,7 +226,7 @@ export const RECIPES = {
206
226
  GEMINI_API_KEY: key,
207
227
  GOOGLE_API_KEY: key,
208
228
  }),
209
- args: (m) => (m ? ["--model", `google/${m}`] : []),
229
+ args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("google/") ? (m || DEFAULT_MODEL) : `google/${m || DEFAULT_MODEL}`],
210
230
  },
211
231
  pi: {
212
232
  label: "Pi",
@@ -219,7 +239,7 @@ export const RECIPES = {
219
239
  referer: "https://pi.dev",
220
240
  },
221
241
  env: (key) => ({ PLEUM_API_KEY: key }),
222
- args: (m) => ["--model", m || "gpt-5.4-mini"],
242
+ args: (m) => ["--model", m || DEFAULT_MODEL],
223
243
  },
224
244
  crush: {
225
245
  label: "Crush",
@@ -246,9 +266,9 @@ export const RECIPES = {
246
266
  env: (key, model) => ({
247
267
  OPENAI_BASE_URL: V1,
248
268
  OPENAI_API_KEY: key,
249
- ...(model ? { OPENAI_MODEL: model } : {}),
269
+ OPENAI_MODEL: model || DEFAULT_MODEL,
250
270
  }),
251
- args: (m) => (m ? ["--model", m] : []),
271
+ args: (m) => ["--model", m || DEFAULT_MODEL],
252
272
  },
253
273
  mimo: {
254
274
  label: "MiMo Code",
@@ -262,9 +282,9 @@ export const RECIPES = {
262
282
  env: (key, model) => ({
263
283
  OPENAI_BASE_URL: V1,
264
284
  OPENAI_API_KEY: key,
265
- ...(model ? { OPENAI_MODEL: model } : {}),
285
+ OPENAI_MODEL: model || DEFAULT_MODEL,
266
286
  }),
267
- args: (m) => (m ? ["--model", m] : []),
287
+ args: (m) => ["--model", m || DEFAULT_MODEL],
268
288
  },
269
289
  openclaw: {
270
290
  label: "OpenClaw",
@@ -278,9 +298,9 @@ export const RECIPES = {
278
298
  env: (key, model) => ({
279
299
  OPENAI_BASE_URL: V1,
280
300
  OPENAI_API_KEY: key,
281
- ...(model ? { OPENAI_MODEL: model } : {}),
301
+ OPENAI_MODEL: model || DEFAULT_MODEL,
282
302
  }),
283
- args: (m) => (m ? ["--model", m] : []),
303
+ args: (m) => ["--model", m || DEFAULT_MODEL],
284
304
  },
285
305
  plandex: {
286
306
  label: "Plandex",
@@ -310,7 +330,7 @@ export const RECIPES = {
310
330
  OPENAI_API_BASE: V1,
311
331
  OPENAI_API_KEY: key,
312
332
  }),
313
- args: (m) => (m ? ["--model", `openai/${m}`] : []),
333
+ args: (m) => ["--model", (m || DEFAULT_MODEL).startsWith("openai/") ? (m || DEFAULT_MODEL) : `openai/${m || DEFAULT_MODEL}`],
314
334
  },
315
335
  };
316
336
 
@@ -357,7 +377,7 @@ export function tomlBasicString(value) {
357
377
  }
358
378
 
359
379
  export function codexToml(model) {
360
- const m = model || "gpt-5.4-mini";
380
+ const m = model || DEFAULT_MODEL;
361
381
  return `# Generated by pleum CLI. Isolated CODEX_HOME — your ~/.codex is untouched.
362
382
  model = ${tomlBasicString(m)}
363
383
  model_provider = "pleum"
@@ -371,7 +391,7 @@ supports_websockets = false
371
391
  `;
372
392
  }
373
393
 
374
- export function mergeAgySettings(existingJson) {
394
+ export function mergeAgySettings(existingJson, model) {
375
395
  let obj = {};
376
396
  if (existingJson && String(existingJson).trim()) {
377
397
  obj = JSON.parse(existingJson);
@@ -379,11 +399,11 @@ export function mergeAgySettings(existingJson) {
379
399
  throw new Error("agy settings.json must be a JSON object");
380
400
  }
381
401
  }
382
- return { ...obj, modelProvider: "gemini" };
402
+ return { ...obj, modelProvider: "gemini", model: model || DEFAULT_MODEL };
383
403
  }
384
404
 
385
405
  export function piModelsJson(model) {
386
- const m = model || "gpt-5.4-mini";
406
+ const m = model || DEFAULT_MODEL;
387
407
  return `${JSON.stringify({
388
408
  providers: {
389
409
  pleum: {
@@ -397,7 +417,7 @@ export function piModelsJson(model) {
397
417
  }
398
418
 
399
419
  export function crushJson(model) {
400
- const m = model || "gpt-5.4-mini";
420
+ const m = model || DEFAULT_MODEL;
401
421
  return `${JSON.stringify({
402
422
  providers: {
403
423
  pleum: {