pleumrouter 0.5.6 → 0.5.8

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,7 +17,7 @@ bun install -g pleumrouter # bun
17
17
  ## 빠른 시작
18
18
 
19
19
  ```bash
20
- npx pleumrouter login # 브라우저 로그인 + 키 자동 저장
20
+ npx pleumrouter login # 브라우저 OAuth 로그인 (API 키 없음)
21
21
  npx pleumrouter launch claude --model claude-sonnet-5
22
22
  npx pleumrouter launch codex --model gpt-5.3-codex
23
23
  npx pleumrouter run gpt-5.4-mini # 터미널 채팅
@@ -25,20 +25,22 @@ npx pleumrouter run gpt-5.4-mini "한 줄 요약: ..." # 1회 실행(스크립
25
25
  cat error.log | npx pleumrouter run gpt-5.4-mini "원인은?" # 파이프
26
26
  ```
27
27
 
28
+ 로그인 후 `~/.pleum/config.json`에 access/refresh 토큰이 저장됩니다. `plm_` API 키는 발급되지 않습니다.
29
+ SSH/CI에서는 `pleum login --device`를 쓰세요.
28
30
  ## 명령
29
31
 
30
32
  | 명령 | 설명 |
31
33
  |------|------|
32
34
  | `pleum` | (인자 없음, TTY) 인터랙티브 런처 메뉴 — 키보드 전용. ↑/↓ 이동 · enter 실행 · → 모델 설정 · esc 종료(서브메뉴에선 뒤로) · ctrl+c 즉시 종료. "에이전트 실행" 진입 시 Claude Code·Codex·opencode·omp·Hermes Agent·Qwen Code·GitHub Copilot CLI 등 지원 에이전트 전체를 설치 여부와 함께 볼 수 있고, 미설치는 확인 후 자동 설치(또는 설정 파일 안내). "설정" 진입 시 언어·에이전트별 기본 모델·계정(로그인/로그아웃)·CLI 업데이트·셸 자동완성을 한곳에서 관리. 실행 종료 후 메뉴 복귀. 파이프에선 도움말 출력 |
33
- | `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
34
- | `pleum logout` | 저장된 API 키만 삭제(언어·기본 모델 등 나머지 설정은 유지) |
35
+ | `pleum login [--device]` | 브라우저 OAuth 로그인 → access/refresh 토큰을 `~/.pleum/config.json`에 저장(API 키 미발급). `--device`는 SSH·CI |
36
+ | `pleum logout` | 서버 세션 revoke + 로컬 토큰 삭제(언어·기본 모델 등 나머지 설정은 유지) |
35
37
  | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
36
38
  | `pleum run [model] ["프롬프트"] [--attach image]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. 이미지 업로드는 명시적으로 `--attach ./shot.png`(1회 실행) 또는 REPL의 `@./shot.png`를 사용한다. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
37
39
  | `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
38
40
  | `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
39
41
  | `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
40
42
  | `pleum balance` | 크레딧 잔액 조회 |
41
- | `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — 최신 로그인 키 필요(`usage:read`) |
43
+ | `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — OAuth 로그인 세션 필요 |
42
44
  | `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
43
45
  | `pleum lang [code]` | 표시 언어 조회/변경 — `en`(기본)·`ko`·`ja`·`zh`·`es`. 인자 없이 TTY면 화살표로 선택, `PLEUM_LANG` 환경변수로도 덮어쓰기 가능 |
44
46
  | `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
@@ -78,7 +80,7 @@ env(또는 격리된 `CODEX_HOME`/임시 설정 문자열)만 세팅하고 바
78
80
 
79
81
  ## 환경변수
80
82
 
81
- - `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://apirouter.pleum.ai`). 셀프호스트/스테이징용.
83
+ - `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://router.pleum.ai`). 셀프호스트/스테이징용.
82
84
 
83
85
  ## 개발
84
86
 
package/bin/pleum.mjs CHANGED
@@ -10,7 +10,7 @@ import { top } from "../src/top.mjs";
10
10
  import { health } from "../src/health.mjs";
11
11
  import { completion } from "../src/completion.mjs";
12
12
  import { upgrade } from "../src/upgrade.mjs";
13
- import { readConfig, writeConfig, clearCredentials } from "../src/config.mjs";
13
+ import { readConfig, writeConfig, clearCredentials, V1 } from "../src/config.mjs";
14
14
  import { mainMenu } from "../src/menu.mjs";
15
15
  import { lang } from "../src/lang.mjs";
16
16
  import { RECIPES } from "../src/recipes.mjs";
@@ -21,9 +21,9 @@ import { sanitizeTerminalText } from "../src/ansi.mjs";
21
21
  function buildHelp() {
22
22
  const rows = [
23
23
  ["pleum", t("(no args) interactive launcher menu — keyboard only (TTY only)")],
24
- ["pleum login [--device]", t("Log in via browser + auto-save the key")],
24
+ ["pleum login [--device]", t("Log in via browser (OAuth session, no API key)")],
25
25
  ["", t("--device: browser-less environments (SSH/CI)")],
26
- ["pleum logout", t("Delete the saved key")],
26
+ ["pleum logout", t("Revoke session and clear local tokens")],
27
27
  ["pleum launch [tool] [--model id]", t("Run a coding agent against PleumRouter")],
28
28
  ["", `(${Object.keys(RECIPES).join(" · ")})`],
29
29
  ["", t("omit the tool to see the supported list and install status")],
@@ -87,7 +87,14 @@ try {
87
87
  break;
88
88
  case "logout": {
89
89
  const cfg = readConfig();
90
- if (cfg) writeConfig(clearCredentials(cfg)); // 로그인 이력 없으면(cfg=null) 빈 파일을 새로 만들지 않는다
90
+ if (cfg?.access_token) {
91
+ // best-effort server revoke — 실패해도 로컬 자격증명은 지운다.
92
+ await fetch(`${V1}/auth/logout`, {
93
+ method: "POST",
94
+ headers: { Authorization: `Bearer ${cfg.access_token}` },
95
+ }).catch(() => {});
96
+ }
97
+ if (cfg) writeConfig(clearCredentials(cfg));
91
98
  console.log(`✓ ${t("Logged out")}`);
92
99
  break;
93
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/balance.mjs CHANGED
@@ -1,11 +1,7 @@
1
- import { V1 } from "./config.mjs";
2
- import { requireKey } from "./config.mjs";
1
+ import { authFetch } from "./config.mjs";
3
2
 
4
3
  export async function balance() {
5
- const key = requireKey();
6
- const res = await fetch(`${V1}/credits`, {
7
- headers: { Authorization: `Bearer ${key}` },
8
- });
4
+ const res = await authFetch("/credits");
9
5
  if (!res.ok) {
10
6
  const body = await res.text().catch(() => "");
11
7
  console.error(`잔액 조회 실패 (HTTP ${res.status})${body ? `: ${body}` : ""}`);
package/src/config.mjs CHANGED
@@ -3,9 +3,8 @@ import { join } from "node:path";
3
3
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
4
  import { t } from "./i18n.mjs";
5
5
 
6
- // 공개 게이트웨이 루트(canonical API). 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
7
- // Compat: https://router.pleum.ai 도 180일+ 유지(프론트 /v1 rewrite). 신규 기본은 apirouter.
8
- export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://apirouter.pleum.ai")
6
+ // 공개 게이트웨이 루트. 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
7
+ export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://router.pleum.ai")
9
8
  .replace(/\/+$/, "")
10
9
  .replace(/\/v1$/, ""); // 사용자가 실수로 /v1까지 붙여도 V1이 /v1/v1 이중 경로가 되지 않게 정리(L12)
11
10
  // OpenAI 호환 엔드포인트는 /v1. (Claude Code만 루트를 쓴다 — recipes 참고)
@@ -31,23 +30,137 @@ export function writeConfig(cfg) {
31
30
  }
32
31
 
33
32
  // 로그인 저장 — 기존 설정(lang·launch_models·last_selection)을 보존한 채 자격증명만 병합.
34
- // login/loginDevice가 매번 writeConfig({key, base_url})로 전체를 덮어쓰던 버그의 수정.
35
33
  export function mergeLoginConfig(existing, patch) {
36
- return { ...(existing ?? {}), ...patch };
34
+ const merged = { ...(existing ?? {}), ...patch };
35
+ // undefined patch values strip the key (e.g. remove legacy `key` after OAuth login).
36
+ for (const [k, v] of Object.entries(patch)) {
37
+ if (v === undefined) delete merged[k];
38
+ }
39
+ return merged;
37
40
  }
38
41
 
39
- // 로그아웃 — key·base_url(자격증명)만 제거. lang·launch_models 등 나머지 설정은 보존.
42
+ // 로그아웃 — OAuth 토큰·레거시 key·base_url 제거. lang·launch_models 등 나머지 설정은 보존.
40
43
  export function clearCredentials(existing) {
41
- const { key, base_url, ...rest } = existing ?? {};
44
+ const {
45
+ key,
46
+ base_url,
47
+ access_token,
48
+ refresh_token,
49
+ token_expires_at,
50
+ ...rest
51
+ } = existing ?? {};
42
52
  return rest;
43
53
  }
44
54
 
45
- // 키가 필요한 명령의 공통 가드. 없으면 안내 후 종료.
55
+ /** JWT payload `exp`(epoch sec) — 서명 검증 없이 refresh 트리거 힌트. */
56
+ export function readJwtExp(token) {
57
+ if (typeof token !== "string") return null;
58
+ const parts = token.split(".");
59
+ const segment = parts[1];
60
+ if (!segment) return null;
61
+ try {
62
+ let b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
63
+ const pad = b64.length % 4;
64
+ if (pad) b64 += "=".repeat(4 - pad);
65
+ const json = Buffer.from(b64, "base64").toString("utf8");
66
+ const payload = JSON.parse(json);
67
+ return typeof payload.exp === "number" ? payload.exp : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** access가 곧 만료(기본 60s 여유)면 true. */
74
+ export function accessTokenIsStale(accessToken, skewSeconds = 60) {
75
+ const exp = readJwtExp(accessToken);
76
+ if (exp == null) return true;
77
+ return exp * 1000 <= Date.now() + skewSeconds * 1000;
78
+ }
79
+
80
+ /**
81
+ * 유효한 access_token을 반환. stale이면 refresh. 레거시 `key`(plm_)만 있으면 재로그인 안내.
82
+ */
83
+ export async function requireAuth() {
84
+ const cfg = readConfig();
85
+ if (cfg?.key && !cfg?.access_token) {
86
+ console.error(
87
+ t("Legacy API key login is no longer supported. Run:\n pleum login"),
88
+ );
89
+ process.exit(1);
90
+ }
91
+ if (!cfg?.access_token || !cfg?.refresh_token) {
92
+ console.error(t("Login required. Run this first:\n pleum login"));
93
+ process.exit(1);
94
+ }
95
+ if (!accessTokenIsStale(cfg.access_token)) {
96
+ return cfg.access_token;
97
+ }
98
+ const refreshed = await refreshAccessToken(cfg);
99
+ if (!refreshed) {
100
+ console.error(t("Session expired. Run:\n pleum login"));
101
+ process.exit(1);
102
+ }
103
+ return refreshed;
104
+ }
105
+
106
+ /** @deprecated use requireAuth — kept as alias for gradual call-site migration */
46
107
  export function requireKey() {
108
+ // sync callers still exist; they must switch to await requireAuth().
109
+ // For sync contexts we only check presence and return access_token (no refresh).
47
110
  const cfg = readConfig();
48
- if (!cfg?.key) {
111
+ if (cfg?.key && !cfg?.access_token) {
112
+ console.error(
113
+ t("Legacy API key login is no longer supported. Run:\n pleum login"),
114
+ );
115
+ process.exit(1);
116
+ }
117
+ if (!cfg?.access_token) {
49
118
  console.error(t("Login required. Run this first:\n pleum login"));
50
119
  process.exit(1);
51
120
  }
52
- return cfg.key;
121
+ return cfg.access_token;
122
+ }
123
+
124
+ export async function refreshAccessToken(cfg = readConfig()) {
125
+ if (!cfg?.refresh_token) return null;
126
+ const base = (cfg.base_url || ROOT).replace(/\/+$/, "").replace(/\/v1$/, "");
127
+ try {
128
+ const res = await fetch(`${base}/v1/auth/refresh`, {
129
+ method: "POST",
130
+ headers: { "Content-Type": "application/json" },
131
+ body: JSON.stringify({ refresh_token: cfg.refresh_token }),
132
+ });
133
+ if (!res.ok) return null;
134
+ const data = await res.json();
135
+ if (!data.access_token || !data.refresh_token) return null;
136
+ const exp = readJwtExp(data.access_token);
137
+ writeConfig(
138
+ mergeLoginConfig(cfg, {
139
+ access_token: data.access_token,
140
+ refresh_token: data.refresh_token,
141
+ token_expires_at: exp ? exp * 1000 : undefined,
142
+ key: undefined,
143
+ }),
144
+ );
145
+ return data.access_token;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /** Bearer 요청 — 401이면 1회 refresh 후 재시도. */
152
+ export async function authFetch(path, init = {}) {
153
+ const token = await requireAuth();
154
+ const url = path.startsWith("http") ? path : `${V1}${path.startsWith("/") ? path : `/${path}`}`;
155
+ const headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` };
156
+ let res = await fetch(url, { ...init, headers });
157
+ if (res.status !== 401) return res;
158
+
159
+ const cfg = readConfig();
160
+ const refreshed = await refreshAccessToken(cfg);
161
+ if (!refreshed) return res;
162
+ return fetch(url, {
163
+ ...init,
164
+ headers: { ...(init.headers || {}), Authorization: `Bearer ${refreshed}` },
165
+ });
53
166
  }
package/src/health.mjs CHANGED
@@ -31,7 +31,7 @@ export function renderHealth({ root, health, error, ms, keyPrefix }, styler = st
31
31
  }
32
32
  lines.push(
33
33
  keyPrefix
34
- ? ok(`${t("Logged in")} ${styler.dim(`(${t("key")} ${keyPrefix}…)`)}`)
34
+ ? ok(`${t("Logged in")} ${styler.dim(`(${keyPrefix === "oauth" ? t("OAuth session") : `${t("key")} ${keyPrefix}…`})`)}`)
35
35
  : warn(`${t("Not logged in")} ${styler.dim("— pleum login")}`),
36
36
  );
37
37
  return lines.join("\n");
@@ -39,7 +39,11 @@ 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?.key ? cfg.key.slice(0, 8) : null;
42
+ const keyPrefix = cfg?.access_token
43
+ ? "oauth"
44
+ : cfg?.key
45
+ ? cfg.key.slice(0, 8)
46
+ : null;
43
47
  const t0 = Date.now();
44
48
  let data = null;
45
49
  let error = null;
package/src/launch.mjs CHANGED
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, delimiter, dirname, resolve } from "node:path";
5
- import { requireKey } from "./config.mjs";
5
+ import { requireAuth } from "./config.mjs";
6
6
  import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
7
7
  import { t } from "./i18n.mjs";
8
8
  import { sanitizeTerminalText } from "./ansi.mjs";
@@ -82,7 +82,7 @@ export function launchHelp() {
82
82
 
83
83
  // exit:false면 process.exit 대신 자식 종료 코드를 반환 — 런처 메뉴의 실행 후 복귀용.
84
84
  export async function launch(tool, model, passthrough, { exit = true } = {}) {
85
- const key = requireKey();
85
+ const key = await requireAuth();
86
86
  const r = RECIPES[tool];
87
87
 
88
88
  if (!r) {
@@ -80,6 +80,12 @@ export const ES = {
80
80
  "Log out? [Y/n] ": "¿Cerrar sesión? [Y/n] ",
81
81
  "Logged in": "Sesión iniciada",
82
82
  "Logged in. Saved the key to ~/.pleum/config.json.": "Sesión iniciada. Clave guardada en ~/.pleum/config.json.",
83
+ "Logged in. Saved the session to ~/.pleum/config.json.": "Sesión iniciada. Sesión guardada en ~/.pleum/config.json.",
84
+ "Legacy API key login is no longer supported. Run:\n pleum login": "El inicio de sesión con clave API heredada ya no es compatible. Ejecuta:\n pleum login",
85
+ "Session expired. Run:\n pleum login": "La sesión ha caducado. Ejecuta:\n pleum login",
86
+ "OAuth session": "Sesión OAuth",
87
+ "Log in via browser (OAuth session, no API key)": "Iniciar sesión en el navegador (sesión OAuth, sin clave API)",
88
+ "Revoke session and clear local tokens": "Revocar sesión y borrar tokens locales",
83
89
  "Logged out": "Sesión cerrada",
84
90
  "Login required. Run this first:\n pleum login": "Se requiere iniciar sesión. Ejecuta esto primero:\n pleum login",
85
91
  "Login timed out (2 minutes)": "Tiempo de espera agotado para iniciar sesión (2 minutos)",
@@ -80,6 +80,12 @@ export const JA = {
80
80
  "Log out? [Y/n] ": "ログアウトしますか? [Y/n] ",
81
81
  "Logged in": "ログイン済み",
82
82
  "Logged in. Saved the key to ~/.pleum/config.json.": "ログインしました。キーを ~/.pleum/config.json に保存しました。",
83
+ "Logged in. Saved the session to ~/.pleum/config.json.": "ログインしました。セッションを ~/.pleum/config.json に保存しました。",
84
+ "Legacy API key login is no longer supported. Run:\n pleum login": "レガシー API キーログインはサポートされなくなりました。実行してください:\n pleum login",
85
+ "Session expired. Run:\n pleum login": "セッションの有効期限が切れました。実行してください:\n pleum login",
86
+ "OAuth session": "OAuth セッション",
87
+ "Log in via browser (OAuth session, no API key)": "ブラウザでログイン(OAuth セッション、API キーなし)",
88
+ "Revoke session and clear local tokens": "セッションを取り消し、ローカル トークンを削除",
83
89
  "Logged out": "ログアウトしました",
84
90
  "Login required. Run this first:\n pleum login": "ログインが必要です。まず以下を実行してください:\n pleum login",
85
91
  "Login timed out (2 minutes)": "ログインがタイムアウトしました (2分)",
@@ -26,6 +26,12 @@ export const KO = {
26
26
  "브라우저에서 로그인하세요. 자동으로 안 열리면 아래 URL을 여세요:",
27
27
  "Login timed out (2 minutes)": "로그인 시간 초과(2분)",
28
28
  "Logged in. Saved the key to ~/.pleum/config.json.": "로그인 완료. 키를 ~/.pleum/config.json 에 저장했습니다.",
29
+ "Logged in. Saved the session to ~/.pleum/config.json.": "로그인 완료. 세션을 ~/.pleum/config.json 에 저장했습니다.",
30
+ "Legacy API key login is no longer supported. Run:\n pleum login": "레거시 API 키 로그인은 더 이상 지원되지 않습니다. 실행하세요:\n pleum login",
31
+ "Session expired. Run:\n pleum login": "세션이 만료되었습니다. 실행하세요:\n pleum login",
32
+ "OAuth session": "OAuth 세션",
33
+ "Log in via browser (OAuth session, no API key)": "브라우저로 로그인 (OAuth 세션, API 키 없음)",
34
+ "Revoke session and clear local tokens": "세션 폐기 및 로컬 토큰 삭제",
29
35
  "Failed to issue a device code (server responded {status})": "device code 발급 실패 (서버 응답 {status})",
30
36
  "Code": "코드",
31
37
  "Log in at the URL above, then enter the code (within {min} min).": "위 URL에서 로그인 후 코드를 입력하세요 ({min}분 내).",
@@ -80,6 +80,12 @@ export const ZH = {
80
80
  "Log out? [Y/n] ": "要登出吗? [Y/n] ",
81
81
  "Logged in": "已登录",
82
82
  "Logged in. Saved the key to ~/.pleum/config.json.": "已登录。密钥已保存至 ~/.pleum/config.json。",
83
+ "Logged in. Saved the session to ~/.pleum/config.json.": "已登录。会话已保存至 ~/.pleum/config.json。",
84
+ "Legacy API key login is no longer supported. Run:\n pleum login": "不再支持旧版 API 密钥登录。请运行:\n pleum login",
85
+ "Session expired. Run:\n pleum login": "会话已过期。请运行:\n pleum login",
86
+ "OAuth session": "OAuth 会话",
87
+ "Log in via browser (OAuth session, no API key)": "通过浏览器登录(OAuth 会话,无 API 密钥)",
88
+ "Revoke session and clear local tokens": "撤销会话并清除本地令牌",
83
89
  "Logged out": "已登出",
84
90
  "Login required. Run this first:\n pleum login": "需要登录。请先运行:\n pleum login",
85
91
  "Login timed out (2 minutes)": "登录超时(2 分钟)",
package/src/login.mjs CHANGED
@@ -24,12 +24,29 @@ function openBrowser(url) {
24
24
  }).unref();
25
25
  }
26
26
 
27
+ function saveTokens({ access_token, refresh_token, expires_in }) {
28
+ const existing = readConfig() || {};
29
+ const { key: _legacy, ...rest } = existing;
30
+ const token_expires_at =
31
+ typeof expires_in === "number"
32
+ ? Date.now() + expires_in * 1000
33
+ : undefined;
34
+ writeConfig(
35
+ mergeLoginConfig(rest, {
36
+ access_token,
37
+ refresh_token,
38
+ token_expires_at,
39
+ base_url: ROOT,
40
+ }),
41
+ );
42
+ }
43
+
27
44
  // 브라우저 핸드오프: 로컬 콜백 서버를 열고 대시보드 /cli-auth로 보낸다.
28
- // 프론트가 로그인 확인 후 키를 발급해 127.0.0.1:PORT/cb 로 POST 한다(URL에 키 미노출).
45
+ // 프론트가 로그인 확인 후 JWT를 발급해 127.0.0.1:PORT/cb 로 POST 한다.
29
46
  export async function login() {
30
47
  const state = randomUUID();
31
48
 
32
- const key = await new Promise((resolve, reject) => {
49
+ const tokens = await new Promise((resolve, reject) => {
33
50
  const server = createServer((req, res) => {
34
51
  // 로컬 콜백만 받으므로 CORS는 전부 허용(브라우저→127.0.0.1 cross-origin POST).
35
52
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -53,12 +70,22 @@ export async function login() {
53
70
  } catch {
54
71
  // 잘못된 본문 — 아래 검증에서 걸러짐
55
72
  }
56
- const ok = payload.state === state && typeof payload.key === "string" && payload.key;
73
+ const ok =
74
+ payload.state === state &&
75
+ typeof payload.access_token === "string" &&
76
+ payload.access_token &&
77
+ typeof payload.refresh_token === "string" &&
78
+ payload.refresh_token;
57
79
  res.writeHead(ok ? 200 : 400, { "Content-Type": "application/json" });
58
80
  res.end(JSON.stringify({ ok: Boolean(ok) }));
59
81
  server.close();
60
- if (ok) resolve(payload.key);
61
- else reject(new Error(t("State mismatch or missing key")));
82
+ if (ok) {
83
+ resolve({
84
+ access_token: payload.access_token,
85
+ refresh_token: payload.refresh_token,
86
+ expires_in: payload.expires_in,
87
+ });
88
+ } else reject(new Error(t("State mismatch or missing key")));
62
89
  });
63
90
  });
64
91
 
@@ -76,12 +103,12 @@ export async function login() {
76
103
  }, 120_000).unref();
77
104
  });
78
105
 
79
- writeConfig(mergeLoginConfig(readConfig(), { key, base_url: ROOT }));
80
- console.log(`✓ ${t("Logged in. Saved the key to ~/.pleum/config.json.")}`);
106
+ saveTokens(tokens);
107
+ console.log(`✓ ${t("Logged in. Saved the session to ~/.pleum/config.json.")}`);
81
108
  }
82
109
 
83
110
  // device-code 로그인 — 브라우저 없는 환경(SSH·CI).
84
- // 1) code 발급 → 2) 사용자가 /device에서 확인 → 3) CLI가 폴링으로 키 수신.
111
+ // 1) code 발급 → 2) 사용자가 /device에서 확인 → 3) CLI가 폴링으로 JWT 수신.
85
112
  export async function loginDevice() {
86
113
  const res = await fetch(`${V1}/auth/device/code`, { method: "POST" });
87
114
  if (!res.ok) {
@@ -111,9 +138,13 @@ export async function loginDevice() {
111
138
  console.error(t("Error:"), sanitizeTerminalText(e.detail ?? poll.status));
112
139
  process.exit(1);
113
140
  }
114
- const { key } = await poll.json();
115
- writeConfig(mergeLoginConfig(readConfig(), { key, base_url: ROOT }));
116
- console.log(`✓ ${t("Logged in. Saved the key to ~/.pleum/config.json.")}`);
141
+ const data = await poll.json();
142
+ if (!data.access_token || !data.refresh_token) {
143
+ console.error(t("Error:"), "missing tokens");
144
+ process.exit(1);
145
+ }
146
+ saveTokens(data);
147
+ console.log(`✓ ${t("Logged in. Saved the session to ~/.pleum/config.json.")}`);
117
148
  return;
118
149
  }
119
150
  console.error(t("Timed out: the code has expired."));
package/src/run.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { readFileSync, writeFileSync, mkdirSync, lstatSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { V1, requireKey, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
4
+ import { V1, requireAuth, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
5
5
  import { fetchModels, formatTable, matchModels, formatModelLabel, safeModelText } from "./models.mjs";
6
6
  import { style, divider, sanitizeTerminalText } from "./ansi.mjs";
7
7
  import { createMarkdownStream } from "./markdown.mjs";
@@ -200,7 +200,7 @@ export async function run(rest) {
200
200
  let model = argModel;
201
201
  const piped = await readStdin();
202
202
  const oneShot = Boolean(inlinePrompt || piped || attachments.length);
203
- const key = requireKey();
203
+ const key = await requireAuth();
204
204
  const callOpts = { tags, skipCache, reasoningEffort, serviceTier, reasoningMode };
205
205
 
206
206
  if (!model && !oneShot) {
package/src/settings.mjs CHANGED
@@ -26,7 +26,11 @@ export function buildSettingsItems({ lang, loggedIn, keyPrefix, version }) {
26
26
  {
27
27
  id: "settings:account",
28
28
  title: t("Account"),
29
- desc: loggedIn ? `${t("Logged in")} (${t("key")} ${keyPrefix}…)` : t("Not logged in"),
29
+ desc: loggedIn
30
+ ? keyPrefix === "oauth"
31
+ ? `${t("Logged in")} (${t("OAuth session")})`
32
+ : `${t("Logged in")} (${t("key")} ${keyPrefix}…)`
33
+ : t("Not logged in"),
30
34
  },
31
35
  { id: "settings:update", title: t("Update CLI"), desc: t("Current version: {version}", { version }) },
32
36
  { id: "settings:completion", title: t("Shell completion"), desc: t("Print a zsh/bash completion script") },
@@ -65,8 +69,8 @@ async function settingsSelect() {
65
69
  const cfg = readConfig();
66
70
  const items = buildSettingsItems({
67
71
  lang: getLocale(),
68
- loggedIn: Boolean(cfg?.key),
69
- keyPrefix: cfg?.key ? cfg.key.slice(0, 8) : null,
72
+ loggedIn: Boolean(cfg?.access_token || cfg?.key),
73
+ keyPrefix: cfg?.access_token ? "oauth" : cfg?.key ? cfg.key.slice(0, 8) : null,
70
74
  version: readVersion(),
71
75
  });
72
76
  return runList(items, {
package/src/usage.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // `pleum usage [--by model|provider|api_key|day] [--days N]` — 내 지출 리포트.
2
- // GET /v1/usage/report (API 키 + usage:read 스코프 — CLI 로그인 키에 기본 동봉).
2
+ // GET /v1/usage/report (CLI OAuth JWT → 숨은 세션 키 scopes에 usage:read).
3
3
 
4
- import { V1, requireKey } from "./config.mjs";
4
+ import { authFetch } from "./config.mjs";
5
5
  import { style, padDisplay, barChart, divider } from "./ansi.mjs";
6
6
  import { t } from "./i18n.mjs";
7
7
 
@@ -50,12 +50,8 @@ export function renderUsage(data, styler = style) {
50
50
 
51
51
  export async function usage(rest) {
52
52
  const { by, days } = parseUsageArgs(rest);
53
- const key = requireKey();
54
- const res = await fetch(`${V1}/usage/report?group_by=${by}&days=${days}`, {
55
- headers: { Authorization: `Bearer ${key}` },
56
- });
53
+ const res = await authFetch(`/usage/report?group_by=${by}&days=${days}`);
57
54
  if (res.status === 403) {
58
- // 구버전 로그인 키(usage:read 스코프 없음) — 재로그인으로 새 키를 받으면 해결.
59
55
  console.error(
60
56
  `${style.red("✗")} ${t("This API key doesn't have usage:read permission.")}\n` +
61
57
  ` ${style.dim(t("Run pleum login again to get a new key (recent keys include it by default)."))}`,