pleumrouter 0.5.35 → 0.5.36

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 CHANGED
@@ -17,25 +17,28 @@ bun install -g pleumrouter # bun
17
17
  ## 빠른 시작
18
18
 
19
19
  ```bash
20
- npx pleumrouter login # 브라우저 OAuth 로그인 (API 키 없음)
21
- npx pleumrouter launch claude --model claude-sonnet-5
22
- npx pleumrouter launch codex --model gpt-5.3-codex
23
- npx pleumrouter run gpt-5.4-mini # 터미널 채팅
20
+ pleum # Claude Code · Codex · Antigravity · opencode 바로 선택
21
+ pleum claude # Claude Code 바로 실행
22
+ pleum codex # Codex 바로 실행
23
+ pleum agy # Antigravity CLI 바로 실행
24
+ pleum opencode # opencode 바로 실행
25
+ pleum run gpt-5.4-mini # 터미널 채팅
24
26
  npx pleumrouter run gpt-5.4-mini "한 줄 요약: ..." # 1회 실행(스크립트용)
25
27
  cat error.log | npx pleumrouter run gpt-5.4-mini "원인은?" # 파이프
26
28
  ```
27
29
 
28
- 로그인 후 `~/.pleum/config.json`에 access/refresh 토큰이 저장됩니다. `plm_` API 키는 발급되지 않습니다.
30
+ 최초 실행 때 브라우저 로그인을 안내하고, 코딩 에이전트용 `plm_` 키를 한 번 발급해 재사용합니다. 로그인 세션은 `~/.pleum/config.json`, 에이전트 키는 `~/.pleum/wire.json`에 저장됩니다. 두 파일은 사용자만 읽을 수 있습니다. 에이전트 실행은 만료되는 로그인 토큰을 직접 전달하지 않습니다.
29
31
  SSH/CI에서는 `pleum login --device`를 쓰세요.
30
32
  ## 명령
31
33
 
32
34
  | 명령 | 설명 |
33
35
  |------|------|
34
- | `pleum` | (인자 없음, TTY) 인터랙티브 런처 메뉴 — 키보드 전용. ↑/↓ 이동 · enter 실행 · → 모델 설정 · esc 종료(서브메뉴에선 뒤로) · ctrl+c 즉시 종료. "에이전트 실행" 진입 시 Claude Code·Codex·opencode·omp·Hermes Agent·Qwen Code·GitHub Copilot CLI 등 지원 에이전트 전체를 설치 여부와 함께 볼 수 있고, 미설치는 확인 후 자동 설치(또는 설정 파일 안내). "설정" 진입 시 언어·에이전트별 기본 모델·계정(로그인/로그아웃)·CLI 업데이트·셸 자동완성을 한곳에서 관리. 실행 종료 후 메뉴 복귀. 파이프에선 도움말 출력 |
36
+ | `pleum` | (인자 없음, TTY) 첫 화면에서 Claude Code·Codex·Antigravity·opencode를 바로 실행. ↑/↓ 이동 · enter 실행 · → 모델 설정 · esc 종료. 나머지 에이전트는 "에이전트 실행" 메뉴. 파이프에선 도움말 출력 |
37
+ | `pleum claude\|codex\|agy\|opencode [--model id]` | 네 우선 지원 도구를 한 명령으로 실행. 최초 실행은 브라우저 로그인 후 에이전트 키 발급 |
35
38
  | `pleum login [--device]` | 브라우저 OAuth 로그인 → access/refresh 토큰을 `~/.pleum/config.json`에 저장(API 키 미발급). `--device`는 SSH·CI |
36
39
  | `pleum logout` | 서버 세션 revoke + 로컬 토큰 삭제(언어·기본 모델 등 나머지 설정은 유지) |
37
40
  | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
38
- | `pleum wire [claude\|codex]` | Desktop용으로 `~/.claude/settings.json`·`~/.codex/config.toml`에 전용 `plm_` 키를 병합. 생략 시 둘 다. `pleum launch`의 격리 홈과는 별개 |
41
+ | `pleum wire [claude\|codex]` | Desktop용으로 `~/.claude/settings.json`·`~/.codex/config.toml`에 같은 `plm_` 키를 병합. 생략 시 둘 다 |
39
42
  | `pleum unwire [claude\|codex] [--revoke-key]` | wire가 넣은 설정만 제거. `--revoke-key`는 서버 키도 폐기 |
40
43
  | `pleum run [model] ["프롬프트"] [--attach image]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. 이미지 업로드는 명시적으로 `--attach ./shot.png`(1회 실행) 또는 REPL의 `@./shot.png`를 사용한다. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
41
44
  | `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
@@ -75,7 +78,7 @@ SSH/CI에서는 `pleum login --device`를 쓰세요.
75
78
  ## 자동 실행 지원 도구
76
79
 
77
80
  `claude` · `codex` · `aider` · `goose` · `openhands` · `opencode` · `omp` · `hermes` · `qwen` · `copilot` · `gemini` · `agy` · `pi` · `crush` · `minimax` · `mimo` · `openclaw` · `plandex` · `interpreter` —
78
- env 또는 격리 홈(`CODEX_HOME` / `PI_CODING_AGENT_DIR` / `CRUSH_GLOBAL_CONFIG`)만 세팅하고 바로 실행한다.
81
+ env·실행 인자 또는 일부 도구의 격리 홈(`PI_CODING_AGENT_DIR` / `CRUSH_GLOBAL_CONFIG`)으로 연결한다. Codex는 `-c` 임시 설정을 써서 기존 `~/.codex`의 세션과 설정을 유지한다.
79
82
  별칭: `antigravity`→`agy`, `picode`→`pi`, `ohmypi`/`oh-my-pi`→`omp`, `qwencode`/`qwen-code`→`qwen`, `minimax-code`/`mcode`→`minimax`, `mimocode`/`mimo-code`→`mimo`, `open-interpreter`→`interpreter`.
80
83
 
81
84
  `agy`만 사용자 `~/.gemini/antigravity-cli/settings.json`에 `modelProvider=gemini`를 병합한다(제품이 이 파일 없이 키를 무시함). Kiro는 공식 BYOK가 없어 지원하지 않는다.
package/bin/pleum.mjs CHANGED
@@ -30,6 +30,7 @@ function buildHelp() {
30
30
  ["", t("--device: browser-less environments (SSH/CI)")],
31
31
  ["pleum logout", t("Revoke session and clear local tokens")],
32
32
  ["pleum launch [tool] [--model id]", t("Run a coding agent against PleumRouter")],
33
+ ["pleum claude|codex|agy|opencode", t("Run a coding agent against PleumRouter")],
33
34
  ["", `(${Object.keys(RECIPES).join(" · ")})`],
34
35
  ["", t("omit the tool to see the supported list and install status")],
35
36
  ["pleum wire [claude|codex]", t("persist config in ~/.claude or ~/.codex for Desktop apps")],
@@ -55,6 +56,10 @@ function buildHelp() {
55
56
  ["pleum wire", null],
56
57
  ["pleum launch claude --model claude-sonnet-5", null],
57
58
  ["pleum launch codex --model gpt-5.3-codex -- --full-auto", null],
59
+ ["pleum claude", null],
60
+ ["pleum codex", null],
61
+ ["pleum agy", null],
62
+ ["pleum opencode", null],
58
63
  ["pleum run gpt-5.4-mini", t("interactive")],
59
64
  [`pleum run gpt-5.4-mini "${t("Summarize in one line: ...")}"`, t("one-shot")],
60
65
  [`cat error.log | pleum run gpt-5.4-mini "${t("what caused this?")}"`, t("pipe")],
@@ -72,7 +77,10 @@ function buildHelp() {
72
77
  function parseLaunchArgs(args) {
73
78
  const out = { model: undefined, passthrough: [] };
74
79
  for (let i = 0; i < args.length; i++) {
75
- if (args[i] === "--model" || args[i] === "-m") out.model = args[++i];
80
+ if (args[i] === "--model" || args[i] === "-m") {
81
+ if (!args[i + 1] || args[i + 1] === "--") throw new Error("--model requires a model ID");
82
+ out.model = args[++i];
83
+ }
76
84
  else if (args[i] === "--") {
77
85
  out.passthrough.push(...args.slice(i + 1));
78
86
  break;
@@ -101,8 +109,13 @@ try {
101
109
  case "lang":
102
110
  await lang(rest);
103
111
  break;
104
- case "launch": {
105
- const tool = rest[0];
112
+ case "launch":
113
+ case "claude":
114
+ case "codex":
115
+ case "agy":
116
+ case "antigravity":
117
+ case "opencode": {
118
+ const tool = cmd === "launch" ? rest[0] : cmd;
106
119
  if (!tool) {
107
120
  const io = process.stdin;
108
121
  if (io.isTTY && process.stdout.isTTY && typeof io.setRawMode === "function") {
@@ -112,7 +125,7 @@ try {
112
125
  launchHelp();
113
126
  break;
114
127
  }
115
- const { model, passthrough } = parseLaunchArgs(rest.slice(1));
128
+ const { model, passthrough } = parseLaunchArgs(cmd === "launch" ? rest.slice(1) : rest);
116
129
  await launch(tool, model, passthrough);
117
130
  break;
118
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.35",
3
+ "version": "0.5.36",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@
3
3
  import { t } from "./i18n.mjs";
4
4
  import { launchToolNames } from "./recipes.mjs";
5
5
 
6
- const COMMANDS = "login logout lang launch wire unwire run models top balance usage health version update upgrade completion help";
6
+ const COMMANDS = "claude codex agy antigravity opencode login logout lang launch wire unwire run models top balance usage health version update upgrade completion help";
7
7
  const TOOLS = launchToolNames().join(" ");
8
8
 
9
9
  function bash() {
package/src/launch.mjs CHANGED
@@ -2,7 +2,9 @@ import { spawn } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, delimiter, dirname, resolve } from "node:path";
5
- import { requireAuth, getActiveContext } from "./config.mjs";
5
+ import { readConfig, isLoggedIn, getActiveContext } from "./config.mjs";
6
+ import { login } from "./login.mjs";
7
+ import { ensureWireKey } from "./wire.mjs";
6
8
  import {
7
9
  RECIPES,
8
10
  MANUAL,
@@ -93,17 +95,49 @@ export function launchHelp() {
93
95
  console.log(`\n${t("Example: pleum launch claude --model claude-sonnet-5")}`);
94
96
  }
95
97
 
98
+ // 메뉴·단축 명령·`launch`가 모두 같은 저장 모델을 사용한다. 명시적 --model이 우선이다.
99
+ export function resolveLaunchModel(tool, explicit, cfg = readConfig()) {
100
+ const recipe = RECIPES[tool];
101
+ if (recipe?.resolveModel) return recipe.resolveModel(explicit, cfg);
102
+ return explicit || cfg?.launch_models?.[tool] || recipe?.defaultModel;
103
+ }
104
+
105
+ // 공통 실행 파이프라인의 설정 어댑터. 신규 에이전트는 env/args만 쓰거나
106
+ // 별도 홈이 필요할 때 타입 핸들러 하나를 등록하면 된다.
107
+ const SETUP = {
108
+ env: () => null,
109
+ "codex-home": (env, model) => { env.CODEX_HOME = writeIsolatedHome("codex-home", "config.toml", codexToml(model)); return null; },
110
+ "pi-home": (env, model) => { env.PI_CODING_AGENT_DIR = writeIsolatedHome("pi-home", "models.json", piModelsJson(model)); return null; },
111
+ "crush-home": (env, model) => { env.CRUSH_GLOBAL_CONFIG = writeIsolatedHome("crush-home", "crush.json", crushJson(model)); return null; },
112
+ "agy-settings": (_env, model) => setupAgySettings(model),
113
+ };
114
+
115
+ async function agentKey() {
116
+ if (!isLoggedIn()) {
117
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
118
+ throw new Error(t("Login required. Run this first:\n pleum login"));
119
+ }
120
+ await login();
121
+ }
122
+ // 로그인 JWT는 에이전트가 살아 있는 동안 갱신할 수 없다. 한 번 발급한 plm_ 키를
123
+ // launch와 wire가 공유하면 장시간 실행도 유지되고 Go 데이터플레인을 직접 사용한다.
124
+ return (await ensureWireKey()).key;
125
+ }
126
+
96
127
  // exit:false면 process.exit 대신 자식 종료 코드를 반환 — 런처 메뉴의 실행 후 복귀용.
97
- export async function launch(tool, model, passthrough, { exit = true } = {}) {
128
+ export async function launch(tool, model, passthrough, {
129
+ exit = true,
130
+ getKey = agentKey,
131
+ pathCheck = onPath,
132
+ spawnChild = spawn,
133
+ } = {}) {
98
134
  recoverAgySettings();
99
-
100
- const key = await requireAuth();
101
135
  const resolved = resolveLaunchTool(tool);
102
136
  const r = RECIPES[resolved];
103
137
 
104
138
  if (!r) {
105
139
  if (MANUAL[resolved]) {
106
- printManual(resolved, key);
140
+ printManual(resolved, await getKey());
107
141
  return;
108
142
  }
109
143
  const aliases = Object.entries(LAUNCH_ALIASES).map(([from, to]) => `${from} → ${to}`).join(", ");
@@ -116,31 +150,25 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
116
150
  process.exit(1);
117
151
  }
118
152
 
119
- const env = { ...process.env, ...r.env(key, model) };
120
- if (resolved === "claude") {
121
- delete env.ANTHROPIC_API_KEY;
153
+ if (!pathCheck(r.bin)) {
154
+ console.error(`✗ ${t("'{bin}' not found. Install:\n {install}", { bin: r.bin, install: r.install })}`);
155
+ if (exit) process.exit(127);
156
+ return 127;
122
157
  }
123
158
 
124
- // Codex는 격리 CODEX_HOME에 config.toml을 써서 사용자 ~/.codex를 보존한다.
125
- if (r.type === "codex-home") {
126
- env.CODEX_HOME = writeIsolatedHome("codex-home", "config.toml", codexToml(model));
127
- }
128
- if (r.type === "pi-home") {
129
- env.PI_CODING_AGENT_DIR = writeIsolatedHome("pi-home", "models.json", piModelsJson(model));
130
- }
131
- if (r.type === "crush-home") {
132
- env.CRUSH_GLOBAL_CONFIG = writeIsolatedHome("crush-home", "crush.json", crushJson(model));
133
- }
159
+ const key = await getKey();
160
+ model = resolveLaunchModel(resolved, model);
134
161
 
135
- let restoreAgy = null;
136
- if (r.type === "agy-settings") {
137
- restoreAgy = setupAgySettings(model);
138
- }
162
+ const env = { ...process.env, ...r.env(key, model) };
163
+ for (const name of r.unsetEnv || []) delete env[name];
164
+ const setup = SETUP[r.type || "env"];
165
+ if (!setup) throw new Error(`Unknown agent setup type: ${r.type}`);
166
+ let restore = setup(env, model);
139
167
 
140
168
  const cleanup = () => {
141
- if (restoreAgy) {
142
- restoreAgy();
143
- restoreAgy = null;
169
+ if (restore) {
170
+ restore();
171
+ restore = null;
144
172
  }
145
173
  };
146
174
 
@@ -195,7 +223,7 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
195
223
  }
196
224
 
197
225
  return await new Promise((resolve) => {
198
- const child = spawn(command.command, command.args, {
226
+ const child = spawnChild(command.command, command.args, {
199
227
  env,
200
228
  stdio: "inherit",
201
229
  shell: false,
@@ -380,4 +408,3 @@ function printManual(tool, key) {
380
408
  console.log(m.snippet());
381
409
  console.log(`\n${t("2) Export the key and run:")}\n export PLEUM_API_KEY=${key}\n`);
382
410
  }
383
-
package/src/menu.mjs CHANGED
@@ -5,26 +5,21 @@
5
5
  // · Enter = 저장된 모델(launch_models)로 바로 실행, 미설치면 Y/n 확인 후 자동 설치
6
6
  // · 실행 종료 후 메뉴로 복귀(ollama runInteractiveTUI 루프), last_selection 커서 복원
7
7
  // · 키보드 전용: ↑/k ↓/j 이동 · enter/space 실행 · →/l 모델 설정 · esc/q 뒤로(최상위에선 종료) · ctrl+c 즉시 종료
8
- // 에이전트는 최상위에 나열하지 않고 "에이전트 실행" 한 항목으로 묶어 서브메뉴(agentSelect)로 진입한다 —
9
- // 지원 에이전트가 많아질수록 최상위 목록이 늘어지는 것을 막기 위함(runList는 두 메뉴가 공유하는 렌더 루프).
8
+ // 4대 에이전트는 최상위에서 바로 실행하고, 나머지는 "에이전트 실행" 서브메뉴에서 고른다.
10
9
 
11
10
  import { readFileSync } from "node:fs";
12
11
  import { spawn } from "node:child_process";
13
12
  import { createInterface } from "node:readline";
14
13
  import { style, displayWidth, terminalWidth, terminalHeight, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
15
14
  import { keyFeeder, select } from "./interactive.mjs";
16
- import { readConfig, writeConfig, isLoggedIn, touchSession, getActiveContext, isAutoUpdateEnabled } from "./config.mjs";
15
+ import { readConfig, writeConfig, isLoggedIn, getActiveContext } from "./config.mjs";
17
16
  import { RECIPES, MANUAL, DEFAULT_MODEL } from "./recipes.mjs";
18
17
  import { onPath, launch } from "./launch.mjs";
19
18
  import { fetchModels, matchModels, listModels, formatModelLabel, fetchCoreCodingModels, CORE_MODEL_PROVIDERS } from "./models.mjs";
20
19
  import { run } from "./run.mjs";
21
20
  import { login } from "./login.mjs";
22
- import { top } from "./top.mjs";
23
- import { usage } from "./usage.mjs";
24
21
  import { balance } from "./balance.mjs";
25
- import { health } from "./health.mjs";
26
22
  import { runSettings } from "./settings.mjs";
27
- import { checkLatestVersion, performUpdate } from "./update.mjs";
28
23
  import { t } from "./i18n.mjs";
29
24
 
30
25
  // 값은 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점(import 1회)에
@@ -54,11 +49,18 @@ const AGENT_DESC = {
54
49
 
55
50
  const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
56
51
 
57
- // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 에이전트는 없음 — "agents" 한 항목으로 서브메뉴 진입.
58
- // 로그인 항목도 없다 — CLI는 로그인 사용자용이라 mainMenu()가 진입 시점에 게이트로 처리하고,
59
- // 로그인/로그아웃 전환은 설정>계정이 소유한다.
60
- export function buildItems({ savedModels = {}, activeContext = { type: "personal" } }) {
52
+ // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 로그인은 첫 에이전트 실행 시 시작한다.
53
+ export function buildItems({ savedModels = {}, activeContext = { type: "personal" }, installed = {} }) {
61
54
  const items = [];
55
+ for (const tool of ["claude", "codex", "agy", "opencode"]) {
56
+ const r = RECIPES[tool];
57
+ items.push({
58
+ id: `launch:${tool}`,
59
+ tool,
60
+ title: `${r.label}${installed[tool] === false ? ` (${t("install")})` : ""}`,
61
+ desc: `${t(AGENT_DESC[tool])}${savedModels[tool] ? ` · ${t("model")}: ${savedModels[tool]}` : ""}`,
62
+ });
63
+ }
62
64
  items.push({
63
65
  id: "chat",
64
66
  tool: "chat",
@@ -71,10 +73,7 @@ export function buildItems({ savedModels = {}, activeContext = { type: "personal
71
73
  : t("Personal");
72
74
  items.push(
73
75
  { id: "models", title: t("Model list"), desc: t("All models — price and context included") },
74
- { id: "top", title: t("Leaderboard"), desc: t("Model rankings · provider share") },
75
- { id: "usage", title: t("Usage report"), desc: t("Spend over the last 30 days") },
76
76
  { id: "balance", title: t("Balance"), desc: `${t("Check your credit balance")} · ${contextDesc}` },
77
- { id: "health", title: t("Health check"), desc: t("Gateway connectivity · login status") },
78
77
  { id: "settings", title: t("Settings"), desc: t("Language, default models, account, updates") },
79
78
  );
80
79
  return items;
@@ -207,7 +206,7 @@ export async function pickClaudeCodeTiers(agentItem, { input = process.stdin, ou
207
206
  const defaultTiers = {
208
207
  sonnet: "claude-sonnet-5",
209
208
  opus: "claude-opus-5-5",
210
- haiku: DEFAULT_MODEL,
209
+ haiku: "claude-haiku-4-5",
211
210
  };
212
211
  const baseTiers = {
213
212
  sonnet: savedTiers?.sonnet || defaultTiers.sonnet,
@@ -483,26 +482,17 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
483
482
  });
484
483
  }
485
484
 
486
- async function menuSelect(updateInfo = null) {
485
+ async function menuSelect() {
487
486
  const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
488
487
  const cfg = readConfig();
489
488
  const items = buildItems({
490
489
  savedModels: cfg?.launch_models ?? {},
491
490
  activeContext: getActiveContext(cfg),
491
+ installed: Object.fromEntries(["claude", "codex", "agy", "opencode"].map((tool) => [tool, onPath(RECIPES[tool].bin)])),
492
492
  });
493
493
  const last = cfg?.last_selection;
494
494
  const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
495
495
  const headerLines = [`${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`];
496
- if (updateInfo?.isOutdated) {
497
- headerLines.push(
498
- style.yellow(
499
- `💡 ${t("Update available: v{current} → v{latest}. Run 'pleum update' to update.", {
500
- current: updateInfo.currentVersion,
501
- latest: updateInfo.latestVersion,
502
- })}`,
503
- ),
504
- );
505
- }
506
496
  return runList(items, {
507
497
  header: headerLines.join("\n"),
508
498
  footer: t("↑/↓ move • enter run • → set model • esc quit"),
@@ -522,13 +512,10 @@ async function agentSelect() {
522
512
  }
523
513
 
524
514
  // 에이전트 실행 메뉴 — bare pleum의 "에이전트 실행"과 pleum launch (인자 없음)가 공유.
525
- // 에이전트를 고르면 곧바로 모델/티어/추론 피커를 띄워 기본 모델 또는
526
- // 선택한 우리 모델로 코딩 에이전트를 실행한다.
515
+ // Enter는 저장 모델로 즉시 실행하고, →는 모델 선택 후 실행한다.
527
516
  export async function runAgentMenu({ loop = false } = {}) {
528
517
  const io = process.stdin;
529
518
  if (!io.isTTY || !process.stdout.isTTY || typeof io.setRawMode !== "function") return false;
530
- if (!isLoggedIn()) await login();
531
- else await touchSession().catch(() => {});
532
519
 
533
520
  for (;;) {
534
521
  const agentPicked = await agentSelect();
@@ -548,54 +535,8 @@ export async function runAgentMenu({ loop = false } = {}) {
548
535
  continue;
549
536
  }
550
537
 
551
- const agentSaved = readConfig()?.launch_models ?? {};
552
- let launchModelPayload = null;
553
-
554
- if (agentItem.tool === "claude") {
555
- const tiers = await pickClaudeCodeTiers(agentItem);
556
- if (!tiers) continue;
557
- launchModelPayload = tiers;
558
- saveModel("claude", tiers.sonnet);
559
- } else if (agentItem.tool === "codex") {
560
- let chosenModel = agentPicked.model;
561
- if (!chosenModel) {
562
- const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
563
- chosenModel = await pickModelFor(agentItem, defaultForTool);
564
- if (!chosenModel) continue;
565
- }
566
- saveModel("codex", chosenModel);
567
- const cfg = readConfig() ?? {};
568
- const effort = await pickCodexEffort(cfg.codex_effort || "medium");
569
- if (!effort) continue;
570
- cfg.codex_effort = effort;
571
- writeConfig(cfg);
572
- launchModelPayload = { model: chosenModel, effort };
573
- } else if (agentItem.tool === "agy") {
574
- let chosenModel = agentPicked.model;
575
- if (!chosenModel) {
576
- const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
577
- chosenModel = await pickModelFor(agentItem, defaultForTool);
578
- if (!chosenModel) continue;
579
- }
580
- saveModel("agy", chosenModel);
581
- const cfg = readConfig() ?? {};
582
- const effort = await pickAgyEffort(cfg.agy_effort || "high");
583
- if (!effort) continue;
584
- cfg.agy_effort = effort;
585
- writeConfig(cfg);
586
- launchModelPayload = { model: chosenModel, effort };
587
- } else {
588
- let chosenModel = agentPicked.model;
589
- if (!chosenModel) {
590
- const defaultForTool = agentSaved[agentItem.tool] || DEFAULT_MODEL;
591
- chosenModel = await pickModelFor(agentItem, defaultForTool);
592
- if (!chosenModel) continue;
593
- }
594
- saveModel(agentItem.tool, chosenModel);
595
- launchModelPayload = chosenModel;
596
- }
597
-
598
- await launch(agentItem.tool, launchModelPayload, [], { exit: false });
538
+ if (agentPicked.model) saveModel(agentItem.tool, agentPicked.model);
539
+ await launch(agentItem.tool, agentPicked.model, [], { exit: false });
599
540
  console.log();
600
541
  if (!loop) return true;
601
542
  }
@@ -608,30 +549,8 @@ export async function mainMenu() {
608
549
  // runList()와 같은 3조건을 쓴다. 이 가드가 없으면 `pleum | cat`이 브라우저를 열고 2분 매달린다.
609
550
  const io = process.stdin;
610
551
  if (!io.isTTY || !process.stdout.isTTY || typeof io.setRawMode !== "function") return false;
611
- // CLI는 로그인 사용자용이다 — 미로그인 진입이면 여기서 auth 로그인을 태운다.
612
- // login()의 실패(취소·2분 타임아웃·state 불일치)는 그대로 던져 bin/pleum.mjs의
613
- // 최상위 catch가 `✗ <메시지>` + exit 1로 처리하게 둔다(메뉴를 띄우지 않는다).
614
- if (!isLoggedIn()) await login();
615
- else await touchSession().catch(() => {});
616
-
617
- let updateInfo = null;
618
- try {
619
- updateInfo = await checkLatestVersion();
620
- if (updateInfo?.isOutdated) {
621
- if (isAutoUpdateEnabled()) {
622
- console.log(`💡 ${t("Update available: v{current} → v{latest}", { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`);
623
- console.log(`▶ ${t("Auto-updating PleumRouter CLI...")}\n`);
624
- const code = await performUpdate();
625
- if (code === 0) {
626
- console.log(`\n✓ ${t("Update completed! Please run `pleum` again.")}`);
627
- return true;
628
- }
629
- }
630
- }
631
- } catch {}
632
-
633
552
  for (;;) {
634
- const picked = await menuSelect(updateInfo);
553
+ const picked = await menuSelect();
635
554
  if (picked === undefined) return false; // 비TTY
636
555
  if (picked === null || picked === "quit") return true; // esc/q/ctrl+c — ollama처럼 조용히 종료
637
556
 
@@ -642,6 +561,7 @@ export async function mainMenu() {
642
561
 
643
562
  switch (item.id) {
644
563
  case "chat": {
564
+ if (!isLoggedIn()) await login();
645
565
  const m = model ?? saved.chat;
646
566
  await run(m ? [m] : []); // REPL 종료는 run 내부 process.exit — 메뉴 복귀 없음
647
567
  return true;
@@ -653,20 +573,6 @@ export async function mainMenu() {
653
573
  console.error(`✗ ${sanitizeTerminalText(e.message)}`);
654
574
  }
655
575
  continue;
656
- case "top":
657
- try {
658
- await top([]);
659
- } catch (e) {
660
- console.error(`✗ ${sanitizeTerminalText(e.message)}`);
661
- }
662
- continue;
663
- case "usage":
664
- try {
665
- await usage([]);
666
- } catch (e) {
667
- console.error(`✗ ${sanitizeTerminalText(e.message)}`);
668
- }
669
- continue;
670
576
  case "balance":
671
577
  try {
672
578
  await balance({ interactive: true, exitOnError: false });
@@ -674,13 +580,6 @@ export async function mainMenu() {
674
580
  console.error(`✗ ${sanitizeTerminalText(e.message)}`);
675
581
  }
676
582
  continue;
677
- case "health":
678
- try {
679
- await health({ exitOnError: false });
680
- } catch (e) {
681
- console.error(`✗ ${sanitizeTerminalText(e.message)}`);
682
- }
683
- continue;
684
583
  case "settings": {
685
584
  const r = await runSettings();
686
585
  if (r === "quit") return true; // ctrl+c 또는 재설치(upgrade) 시작 — 전체 종료
@@ -691,6 +590,15 @@ export async function mainMenu() {
691
590
  if (r === "quit") return true;
692
591
  continue;
693
592
  }
593
+ case "launch:claude":
594
+ case "launch:codex":
595
+ case "launch:agy":
596
+ case "launch:opencode": {
597
+ const tool = item.tool;
598
+ if (!onPath(RECIPES[tool].bin) && !(await ensureInstalled(RECIPES[tool]))) continue;
599
+ await launch(tool, model, [], { exit: false });
600
+ continue;
601
+ }
694
602
  }
695
603
  }
696
604
  }
package/src/recipes.mjs CHANGED
@@ -17,7 +17,7 @@ export const CORE_MODELS = [
17
17
 
18
18
  // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/{codex,claude-code,...} + 에이전트별 공식 문서.
19
19
  // type "env" → 환경변수만 세팅하고 바로 실행.
20
- // type "codex-home" → 격리 CODEX_HOME에 config.toml (사용자 ~/.codex 보존).
20
+ // Codex는 -c config override로 사용자 CODEX_HOME을 유지하면서 provider만 이번 실행에 덮어쓴다.
21
21
  // type "pi-home" → 격리 PI_CODING_AGENT_DIR에 models.json (사용자 ~/.pi 보존).
22
22
  // type "crush-home" → 격리 CRUSH_GLOBAL_CONFIG 디렉터리에 crush.json (사용자 crush.json 보존).
23
23
  // type "agy-settings" → ~/.gemini/antigravity-cli/settings.json 에 modelProvider=gemini 병합.
@@ -34,6 +34,15 @@ export const RECIPES = {
34
34
  label: "Claude Code",
35
35
  bin: "claude",
36
36
  type: "env",
37
+ unsetEnv: ["ANTHROPIC_API_KEY"],
38
+ resolveModel: (explicit, cfg) => {
39
+ if (explicit && typeof explicit === "object") return explicit;
40
+ return {
41
+ sonnet: explicit || cfg?.launch_models?.claude || cfg?.claude_tiers?.sonnet || "claude-sonnet-5",
42
+ opus: cfg?.claude_tiers?.opus || "claude-opus-5-5",
43
+ haiku: cfg?.claude_tiers?.haiku || "claude-haiku-4-5",
44
+ };
45
+ },
37
46
  install: "npm i -g @anthropic-ai/claude-code",
38
47
  // 앱 랭킹 귀속 메타 — 백엔드 featured_agents / UA fallback과 동기화.
39
48
  attribution: {
@@ -46,8 +55,10 @@ export const RECIPES = {
46
55
  const isObj = m && typeof m === "object";
47
56
  const sonnet = (isObj ? m.sonnet : m) || "claude-sonnet-5";
48
57
  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}`);
58
+ const haiku = (isObj ? m.haiku : null) || "claude-haiku-4-5";
59
+ // Claude 모델의 provider-qualified ID만 보정한다. 다른 회사 모델에
60
+ // anthropic/을 붙이면 카탈로그에 없는 ID가 되어 요청이 실패한다.
61
+ const toAnthropic = (x) => /^claude-/.test(x) ? `anthropic/${x}` : x;
51
62
  return {
52
63
  ANTHROPIC_BASE_URL: ROOT,
53
64
  ANTHROPIC_AUTH_TOKEN: key,
@@ -73,23 +84,44 @@ export const RECIPES = {
73
84
  codex: {
74
85
  label: "Codex CLI",
75
86
  bin: "codex",
76
- type: "codex-home",
87
+ type: "env",
88
+ defaultModel: "gpt-6-sol",
89
+ resolveModel: (explicit, cfg) => explicit && typeof explicit === "object"
90
+ ? explicit
91
+ : { model: explicit || cfg?.launch_models?.codex || "gpt-6-sol", effort: cfg?.codex_effort || "medium" },
77
92
  install: "npm i -g @openai/codex",
78
93
  attribution: {
79
94
  title: "Codex",
80
95
  referer: "https://github.com/openai/codex",
81
96
  },
82
97
  env: (key) => ({ PLEUM_API_KEY: key }),
83
- args: (m) => {
84
- const isObj = m && typeof m === "object";
85
- const effort = isObj ? m.effort : null;
86
- return effort ? ["-c", `model_reasoning_effort="${effort}"`] : [];
98
+ args: (model) => {
99
+ const m = model && typeof model === "object" ? model.model : model;
100
+ const effort = model && typeof model === "object" ? model.effort : null;
101
+ const provider = {
102
+ name: "PleumRouter",
103
+ base_url: V1,
104
+ env_key: "PLEUM_API_KEY",
105
+ wire_api: "responses",
106
+ supports_websockets: false,
107
+ };
108
+ const providerToml = `{ ${Object.entries(provider).map(([k, v]) => `${k} = ${typeof v === "boolean" ? v : tomlBasicString(v)}`).join(", ")} }`;
109
+ return [
110
+ "-c", 'model_provider="pleum"',
111
+ "-c", `model_providers.pleum=${providerToml}`,
112
+ "--model", m || "gpt-6-sol",
113
+ ...(effort ? ["-c", `model_reasoning_effort=${tomlBasicString(effort)}`] : []),
114
+ ];
87
115
  },
88
116
  },
89
117
  agy: {
90
118
  label: "Antigravity CLI",
91
119
  bin: "agy",
92
120
  type: "agy-settings",
121
+ defaultModel: "gemini-3.8-flash",
122
+ resolveModel: (explicit, cfg) => explicit && typeof explicit === "object"
123
+ ? explicit
124
+ : { model: explicit || cfg?.launch_models?.agy || "gemini-3.8-flash", effort: cfg?.agy_effort || "high" },
93
125
  install: "https://antigravity.google/cli/install.sh",
94
126
  note: "Writes modelProvider=gemini into ~/.gemini/antigravity-cli/settings.json. Routes any coding model via PleumRouter.",
95
127
  attribution: {
@@ -103,35 +135,40 @@ export const RECIPES = {
103
135
  }),
104
136
  args: (m) => {
105
137
  const isObj = m && typeof m === "object";
106
- const model = (isObj ? m.model : m) || DEFAULT_MODEL;
138
+ const model = (isObj ? m.model : m) || "gemini-3.8-flash";
107
139
  const effort = isObj ? m.effort : null;
108
- const res = ["--model", model];
109
- if (effort) res.push("--effort", effort);
110
- return res;
140
+ // agy의 --model은 내장 Gemini 모델만 받는다. 다른 카탈로그 ID는
141
+ // settings.json model로 전달하고 CLI 인자에는 싣지 않는다.
142
+ return /^(?:google\/)?gemini-/.test(model)
143
+ ? ["--model", model.replace(/^google\//, ""), "--effort", effort || "high"]
144
+ : [];
111
145
  },
112
146
  },
113
147
  opencode: {
114
148
  label: "opencode",
115
149
  bin: "opencode",
116
150
  type: "env",
151
+ defaultModel: "gpt-6-sol",
117
152
  install: "npm i -g opencode-ai@latest",
118
- // opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다 — env var는 없다.
119
- // 대신 OPENCODE_CONFIG_CONTENT(파일 대신 JSON 문자열을 그대로 읽는 env var)로 파일 없이 주입한다.
153
+ // opencode는 커스텀 엔드포인트를 opencode.json provider 블록으로만 읽는다.
154
+ // OpenCode는 openai 공급자 ID에만 Responses용 getModel을 등록한다.
155
+ // 사용자 설정 파일을 수정하지 않고 이번 프로세스의 openai 공급자를 Pleum으로 덮는다.
156
+ // OPENCODE_CONFIG_CONTENT로 이번 실행에만 공급자를 주입한다.
120
157
  note: "Generates a temporary PleumRouter provider config for this run only — your own opencode.json is untouched.",
121
158
  attribution: {
122
159
  title: "opencode",
123
160
  referer: "https://opencode.ai",
124
161
  },
125
162
  env: (key, model) => {
126
- const active = model || DEFAULT_MODEL;
163
+ const active = model || "gpt-6-sol";
127
164
  const models = Object.fromEntries(CORE_MODELS.map((id) => [id, {}]));
128
165
  models[active] = {};
129
166
  return {
130
167
  PLEUM_API_KEY: key,
131
168
  OPENCODE_CONFIG_CONTENT: JSON.stringify({
132
169
  provider: {
133
- pleum: {
134
- npm: "@ai-sdk/openai-compatible",
170
+ openai: {
171
+ npm: "@ai-sdk/openai",
135
172
  name: "PleumRouter",
136
173
  options: { baseURL: V1, apiKey: "{env:PLEUM_API_KEY}" },
137
174
  models,
@@ -140,7 +177,7 @@ export const RECIPES = {
140
177
  }),
141
178
  };
142
179
  },
143
- args: (m) => ["--model", `pleum/${m || DEFAULT_MODEL}`],
180
+ args: (m) => ["--model", `openai/${m || "gpt-6-sol"}`],
144
181
  },
145
182
  aider: {
146
183
  label: "Aider",
@@ -408,7 +445,7 @@ export function tomlBasicString(value) {
408
445
 
409
446
  export function codexToml(model, effort) {
410
447
  const isObj = model && typeof model === "object";
411
- const m = (isObj ? model.model : model) || DEFAULT_MODEL;
448
+ const m = (isObj ? model.model : model) || "gpt-6-sol";
412
449
  const eff = (isObj ? model.effort : effort) || null;
413
450
  const effortLine = eff ? `model_reasoning_effort = ${tomlBasicString(eff)}\n` : "";
414
451
  return `# Generated by pleum CLI. Isolated CODEX_HOME — your ~/.codex is untouched.
@@ -433,7 +470,7 @@ export function mergeAgySettings(existingJson, model) {
433
470
  }
434
471
  }
435
472
  const isObj = model && typeof model === "object";
436
- const m = (isObj ? model.model : model) || DEFAULT_MODEL;
473
+ const m = (isObj ? model.model : model) || "gemini-3.8-flash";
437
474
  return { ...obj, modelProvider: "gemini", model: m };
438
475
  }
439
476
 
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, isAutoUpdateEnabled, setAutoUpdateEnabled } from "./config.mjs";
11
+ import { readConfig, writeConfig, isLoggedIn, getActiveContext } 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";
@@ -27,7 +27,6 @@ export function buildSettingsItems({
27
27
  keyPrefix,
28
28
  version,
29
29
  activeContext = { type: "personal" },
30
- autoUpdate = true,
31
30
  latestVersion = null,
32
31
  }) {
33
32
  const contextDesc = activeContext?.type === "team"
@@ -51,11 +50,6 @@ export function buildSettingsItems({
51
50
  : `${t("Logged in")} (${t("key")} ${keyPrefix}…)`
52
51
  : t("Not logged in"),
53
52
  },
54
- {
55
- id: "settings:auto_update",
56
- title: t("Auto update"),
57
- desc: autoUpdate ? t("Enabled (updates automatically on start)") : t("Disabled"),
58
- },
59
53
  { id: "settings:update", title: t("Update CLI"), desc: updateDesc },
60
54
  { id: "settings:completion", title: t("Shell completion"), desc: t("Print a zsh/bash completion script") },
61
55
  ];
@@ -101,7 +95,6 @@ async function settingsSelect() {
101
95
  keyPrefix: cfg?.access_token ? "oauth" : cfg?.key ? cfg.key.slice(0, 8) : null,
102
96
  version: readVersion(),
103
97
  activeContext: getActiveContext(cfg),
104
- autoUpdate: isAutoUpdateEnabled(cfg),
105
98
  latestVersion: updateInfo?.latestVersion,
106
99
  });
107
100
  return runList(items, {
@@ -188,11 +181,6 @@ export async function runSettings() {
188
181
  case "settings:account":
189
182
  await runAccount();
190
183
  continue;
191
- case "settings:auto_update": {
192
- const next = !isAutoUpdateEnabled();
193
- setAutoUpdateEnabled(next);
194
- continue;
195
- }
196
184
  case "settings:update":
197
185
  console.log(t("Updating pleumrouter CLI..."));
198
186
  await runUpdateCommand([], { exit: false });
package/src/wire.mjs CHANGED
@@ -69,12 +69,22 @@ function removeTomlTable(toml, name) {
69
69
  return out.join("\n");
70
70
  }
71
71
 
72
- function readPreviousProvider(toml) {
73
- const marked = toml.match(/# previous_model_provider = (.*)/);
74
- if (marked) return marked[1].trim();
75
- const stripped = stripWireBlock(toml);
76
- const top = stripped.match(/^model_provider\s*=\s*(.+)$/m);
77
- return top ? top[1].trim() : "";
72
+ function rootValue(toml, name) {
73
+ const root = toml.split(/^\s*\[/m, 1)[0];
74
+ const found = root.match(new RegExp(`^${name}\\s*=\\s*(.+)$`, "m"));
75
+ return found?.[1]?.trim() ?? "";
76
+ }
77
+
78
+ function previousValue(toml, name) {
79
+ const marked = toml.match(new RegExp(`^# previous_${name} = (.*)$`, "m"));
80
+ return marked ? marked[1].trim() : rootValue(stripWireBlock(toml), name);
81
+ }
82
+
83
+ function removeRootValue(toml, name) {
84
+ const firstTable = toml.search(/^\s*\[/m);
85
+ const root = firstTable < 0 ? toml : toml.slice(0, firstTable);
86
+ const rest = firstTable < 0 ? "" : toml.slice(firstTable);
87
+ return root.replace(new RegExp(`^${name}\\s*=\\s*.+\\n?`, "m"), "") + rest;
78
88
  }
79
89
 
80
90
  export function wireClaudeSettings(existingJson, { root, key }) {
@@ -107,18 +117,22 @@ export function unwireClaudeSettings(existingJson) {
107
117
  return `${JSON.stringify(obj, null, 2)}\n`;
108
118
  }
109
119
 
110
- export function wireCodexToml(existingToml, { v1, key }) {
120
+ export function wireCodexToml(existingToml, { v1, key, model = "gpt-6-sol" }) {
111
121
  const src = existingToml ?? "";
112
- const previous = readPreviousProvider(src);
122
+ const previousProvider = previousValue(src, "model_provider");
123
+ const previousModel = previousValue(src, "model");
113
124
  let body = stripWireBlock(src);
114
- body = body.replace(/^model_provider\s*=\s*.+\n?/m, "");
115
125
  body = removeTomlTable(body, "model_providers.pleum.http_headers");
116
126
  body = removeTomlTable(body, "model_providers.pleum");
117
- const block = `${WIRE_MARKER_START}
118
- # previous_model_provider = ${previous}
127
+ body = removeRootValue(removeRootValue(body, "model_provider"), "model");
128
+ const rootBlock = `${WIRE_MARKER_START}
129
+ # previous_model_provider = ${previousProvider}
130
+ # previous_model = ${previousModel}
131
+ model = ${tomlBasicString(model)}
119
132
  model_provider = "pleum"
120
-
121
- [model_providers.pleum]
133
+ ${WIRE_MARKER_END}
134
+ `;
135
+ const providerBlock = `[model_providers.pleum]
122
136
  name = "PleumRouter"
123
137
  base_url = ${tomlBasicString(v1)}
124
138
  wire_api = "responses"
@@ -126,22 +140,24 @@ supports_websockets = false
126
140
 
127
141
  [model_providers.pleum.http_headers]
128
142
  Authorization = ${tomlBasicString(`Bearer ${key}`)}
129
- ${WIRE_MARKER_END}
130
143
  `;
131
- const trimmed = body.replace(/\s+$/, "");
132
- return `${trimmed ? `${trimmed}\n\n` : ""}${block}`;
144
+ const trimmed = body.trim();
145
+ return `${rootBlock}${trimmed ? `\n${trimmed}\n\n` : "\n"}${providerBlock}`;
133
146
  }
134
147
 
135
148
  export function unwireCodexToml(existingToml) {
136
149
  const src = existingToml ?? "";
137
- const previous = readPreviousProvider(src);
150
+ const previousProvider = previousValue(src, "model_provider");
151
+ const previousModel = previousValue(src, "model");
138
152
  let body = stripWireBlock(src);
139
153
  body = removeTomlTable(body, "model_providers.pleum.http_headers");
140
154
  body = removeTomlTable(body, "model_providers.pleum");
141
- if (previous && previous !== '""' && !/^model_provider\s*=/m.test(body)) {
142
- body = `model_provider = ${previous}\n${body.replace(/^\n+/, "")}`;
143
- }
144
- return body.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
155
+ body = removeRootValue(removeRootValue(body, "model_provider"), "model");
156
+ const restored = [
157
+ previousModel ? `model = ${previousModel}` : "",
158
+ previousProvider ? `model_provider = ${previousProvider}` : "",
159
+ ].filter(Boolean).join("\n");
160
+ return `${restored ? `${restored}\n\n` : ""}${body.trim()}`.replace(/\n{3,}/g, "\n\n").trimStart() + "\n";
145
161
  }
146
162
 
147
163
  function parseJsonBody(res) {