pleumrouter 0.5.26 → 0.5.28

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
@@ -41,8 +41,8 @@ SSH/CI에서는 `pleum login --device`를 쓰세요.
41
41
  | `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
42
42
  | `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
43
43
  | `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
44
- | `pleum balance` | 크레딧 잔액 조회 |
45
- | `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — OAuth 로그인 세션 필요 |
44
+ | `pleum balance [--switch]` | 크레딧 잔액 조회 · 워크스페이스(개인/팀) 전환 |
45
+ | `pleum usage [--by model\|provider\|api_key\|day\|tag\|user] [--days N]` | 내 지출 리포트(막대 차트) — OAuth 로그인 세션 필요 |
46
46
  | `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
47
47
  | `pleum lang [code]` | 표시 언어 조회/변경 — `en`(기본)·`ko`·`ja`·`zh`·`es`. 인자 없이 TTY면 화살표로 선택, `PLEUM_LANG` 환경변수로도 덮어쓰기 가능 |
48
48
  | `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
@@ -74,9 +74,9 @@ SSH/CI에서는 `pleum login --device`를 쓰세요.
74
74
 
75
75
  ## 자동 실행 지원 도구
76
76
 
77
- `claude` · `codex` · `aider` · `goose` · `openhands` · `opencode` · `omp` · `hermes` · `qwen` · `copilot` · `gemini` · `agy` · `pi` · `crush` —
77
+ `claude` · `codex` · `aider` · `goose` · `openhands` · `opencode` · `omp` · `hermes` · `qwen` · `copilot` · `gemini` · `agy` · `pi` · `crush` · `minimax` · `mimo` · `openclaw` · `plandex` · `interpreter` —
78
78
  env 또는 격리 홈(`CODEX_HOME` / `PI_CODING_AGENT_DIR` / `CRUSH_GLOBAL_CONFIG`)만 세팅하고 바로 실행한다.
79
- 별칭: `antigravity`→`agy`, `picode`→`pi`, `ohmypi`→`omp`.
79
+ 별칭: `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
80
 
81
81
  `agy`만 사용자 `~/.gemini/antigravity-cli/settings.json`에 `modelProvider=gemini`를 병합한다(제품이 이 파일 없이 키를 무시함). Kiro는 공식 BYOK가 없어 지원하지 않는다.
82
82
 
package/bin/pleum.mjs CHANGED
@@ -34,8 +34,8 @@ function buildHelp() {
34
34
  ["pleum models [query] [--json]", t("Model list (price, context included)")],
35
35
  ["pleum models <id> --info", t("Model detail (price, perf, per-provider uptime)")],
36
36
  ["pleum top [--period weekly]", t("Model leaderboard · provider share (daily|weekly|monthly)")],
37
- ["pleum balance", t("Check your credit balance")],
38
- ["pleum usage [--by model] [--days 30]", t("Your spend report (model|provider|api_key|day)")],
37
+ ["pleum balance [--switch]", t("Check your credit balance")],
38
+ ["pleum usage [--by model] [--days 30]", t("Your spend report (model|provider|api_key|day|tag|user)")],
39
39
  ["pleum health", t("Check gateway connectivity and login status")],
40
40
  ["pleum lang [code]", t("Show/change display language (en|ko|ja|zh|es)")],
41
41
  ["pleum completion <zsh|bash>", t("Print a shell completion script")],
@@ -124,9 +124,11 @@ try {
124
124
  case "models":
125
125
  await listModels(rest);
126
126
  break;
127
- case "balance":
128
- await balance();
127
+ case "balance": {
128
+ const isSwitch = rest.includes("--switch");
129
+ await balance({ interactive: isSwitch, switchOnly: isSwitch });
129
130
  break;
131
+ }
130
132
  case "usage":
131
133
  await usage(rest);
132
134
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.26",
3
+ "version": "0.5.28",
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,16 +1,186 @@
1
- import { authFetch } from "./config.mjs";
1
+ import { authFetch, getActiveContext, setActiveContext } from "./config.mjs";
2
+ import { style } from "./ansi.mjs";
3
+ import { select } from "./interactive.mjs";
4
+ import { t } from "./i18n.mjs";
2
5
 
3
- export async function balance() {
6
+ export function formatKrwCredit(amount) {
7
+ return Number(amount || 0).toLocaleString("ko-KR", {
8
+ minimumFractionDigits: 1,
9
+ maximumFractionDigits: 1,
10
+ });
11
+ }
12
+
13
+ export async function fetchCreditSummary() {
4
14
  const res = await authFetch("/credits");
5
15
  if (!res.ok) {
6
16
  const body = await res.text().catch(() => "");
7
- console.error(`잔액 조회 실패 (HTTP ${res.status})${body ? `: ${body}` : ""}`);
8
- process.exit(1);
9
- }
10
- const data = await res.json();
11
- const plm = data.balance_krw;
12
- const usd = data.balance_usd != null ? ` / $${data.balance_usd.toFixed(4)}` : "";
13
- // 원장 눈금이 0.1이라 잔액은 소수 1자리 고정 표기(딱 떨어져도 ".0"을 남긴다).
14
- const shown = plm.toLocaleString("ko-KR", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
15
- console.log(`잔액: ${shown} 크레딧${usd}`);
17
+ throw new Error(t("Failed to fetch balance ({status})", { status: res.status }) + (body ? `: ${body}` : ""));
18
+ }
19
+ const personal = await res.json();
20
+
21
+ let orgs = [];
22
+ try {
23
+ const orgRes = await authFetch("/orgs");
24
+ if (orgRes.ok) {
25
+ orgs = await orgRes.json();
26
+ }
27
+ } catch {}
28
+
29
+ let teamWallets = [];
30
+ if (Array.isArray(orgs) && orgs.length > 0) {
31
+ const results = await Promise.all(
32
+ orgs.map(async (org) => {
33
+ try {
34
+ const wRes = await authFetch(`/orgs/${org.id}/wallet`);
35
+ if (wRes.ok) {
36
+ const wallet = await wRes.json();
37
+ return {
38
+ id: String(org.id),
39
+ name: org.name,
40
+ role: org.role,
41
+ balance_krw: wallet.available_krw ?? wallet.balance_krw ?? 0,
42
+ };
43
+ }
44
+ } catch {}
45
+ return null;
46
+ })
47
+ );
48
+ teamWallets = results.filter(Boolean);
49
+ }
50
+
51
+ return {
52
+ personal: {
53
+ balance_krw: personal.balance_krw ?? 0,
54
+ balance_usd: personal.balance_usd,
55
+ },
56
+ teams: teamWallets,
57
+ };
58
+ }
59
+
60
+ export function buildCreditOptions({ personal, teams = [], activeContext = { type: "personal" } }) {
61
+ const options = [];
62
+ const isPersonalActive = activeContext.type === "personal" || !activeContext.org_id;
63
+ const personalBalanceStr = `${formatKrwCredit(personal.balance_krw)} ${t("credits")}${
64
+ personal.balance_usd != null ? ` / $${personal.balance_usd.toFixed(4)}` : ""
65
+ }`;
66
+
67
+ options.push({
68
+ id: "personal",
69
+ type: "personal",
70
+ name: t("Personal"),
71
+ title: t("Personal Credit"),
72
+ balance: personalBalanceStr,
73
+ isActive: isPersonalActive,
74
+ label: `${isPersonalActive ? "✓ " : " "}${t("Personal Credit")}: ${personalBalanceStr}`,
75
+ });
76
+
77
+ for (const team of teams) {
78
+ const isTeamActive = activeContext.type === "team" && String(activeContext.org_id) === String(team.id);
79
+ const teamBalanceStr = `${formatKrwCredit(team.balance_krw)} ${t("credits")}`;
80
+ const roleStr = team.role ? ` (${team.role})` : "";
81
+ const teamTitle = `${t("Team")}: ${team.name}${roleStr}`;
82
+ options.push({
83
+ id: String(team.id),
84
+ type: "team",
85
+ name: team.name,
86
+ title: teamTitle,
87
+ balance: teamBalanceStr,
88
+ isActive: isTeamActive,
89
+ label: `${isTeamActive ? "✓ " : " "}${teamTitle}: ${teamBalanceStr}`,
90
+ });
91
+ }
92
+
93
+ return options;
94
+ }
95
+
96
+ export async function switchCreditWorkspace({ summary, selectFn = select } = {}) {
97
+ let data;
98
+ try {
99
+ data = summary || (await fetchCreditSummary());
100
+ } catch (e) {
101
+ console.error(`✗ ${e.message}`);
102
+ return getActiveContext();
103
+ }
104
+ const active = getActiveContext();
105
+ const options = buildCreditOptions({
106
+ personal: data.personal,
107
+ teams: data.teams,
108
+ activeContext: active,
109
+ });
110
+
111
+ if (options.length <= 1) {
112
+ console.log(t("You are not a member of any team. Using personal credit."));
113
+ return active;
114
+ }
115
+
116
+ const picked = await selectFn(options, {
117
+ message: t("Select credit workspace"),
118
+ formatItem: (x) => x.label,
119
+ limit: 10,
120
+ });
121
+
122
+ if (!picked) return active;
123
+
124
+ const targetOrgId = picked.type === "team" ? picked.id : null;
125
+
126
+ try {
127
+ const res = await authFetch("/auth/cli-context", {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify({ org_id: targetOrgId }),
131
+ });
132
+ if (!res.ok) {
133
+ const err = await res.text().catch(() => "");
134
+ console.error(`✗ ${t("Failed to update credit context")}: ${err}`);
135
+ return active;
136
+ }
137
+ } catch (e) {
138
+ console.error(`✗ ${t("Failed to update credit context")}: ${e.message}`);
139
+ return active;
140
+ }
141
+
142
+ const newContext = setActiveContext({
143
+ type: picked.type,
144
+ org_id: targetOrgId,
145
+ org_name: picked.type === "team" ? picked.name : null,
146
+ });
147
+
148
+ console.log(`✓ ${t("Active credit set to: {name}", { name: picked.title })}`);
149
+ return newContext;
150
+ }
151
+
152
+ export async function balance({ interactive = false, switchOnly = false, exitOnError = true } = {}) {
153
+ let summary;
154
+ try {
155
+ summary = await fetchCreditSummary();
156
+ } catch (e) {
157
+ console.error(`✗ ${e.message}`);
158
+ if (exitOnError) process.exit(1);
159
+ return;
160
+ }
161
+
162
+ if (switchOnly) {
163
+ return await switchCreditWorkspace({ summary });
164
+ }
165
+
166
+ const active = getActiveContext();
167
+ const options = buildCreditOptions({
168
+ personal: summary.personal,
169
+ teams: summary.teams,
170
+ activeContext: active,
171
+ });
172
+
173
+ if (!interactive || options.length <= 1) {
174
+ console.log(t("Credit Balance:"));
175
+ for (const opt of options) {
176
+ const mark = opt.isActive ? style.bold(style.plum("▸ [Active] ")) : " [ ] ";
177
+ console.log(`${mark}${opt.title}: ${opt.balance}`);
178
+ }
179
+ if (options.length > 1 && !interactive) {
180
+ console.log(`\n${style.dim(t("Run `pleum balance --switch` to switch credit workspace."))}`);
181
+ }
182
+ return;
183
+ }
184
+
185
+ return await switchCreditWorkspace({ summary });
16
186
  }
@@ -14,9 +14,14 @@ _pleum() {
14
14
  COMPREPLY=( $(compgen -W "${COMMANDS}" -- "$cur") ); return
15
15
  fi
16
16
  case "$prev" in
17
- launch) COMPREPLY=( $(compgen -W "${TOOLS}" -- "$cur") );;
18
- wire|unwire) COMPREPLY=( $(compgen -W "claude codex" -- "$cur") );;
19
- run) COMPREPLY=( $(compgen -W "$(pleum models 2>/dev/null | awk 'NR>1{print $1}')" -- "$cur") );;
17
+ launch) COMPREPLY=( $(compgen -W "${TOOLS} --model" -- "$cur") );;
18
+ wire|unwire) COMPREPLY=( $(compgen -W "claude codex --revoke-key" -- "$cur") );;
19
+ run|models) COMPREPLY=( $(compgen -W "$(pleum models 2>/dev/null | awk 'NR>1{print $1}') --json --info" -- "$cur") );;
20
+ balance) COMPREPLY=( $(compgen -W "--switch" -- "$cur") );;
21
+ usage) COMPREPLY=( $(compgen -W "--by --days" -- "$cur") );;
22
+ --by) COMPREPLY=( $(compgen -W "model provider api_key day tag user" -- "$cur") );;
23
+ lang) COMPREPLY=( $(compgen -W "en ko ja zh es" -- "$cur") );;
24
+ top) COMPREPLY=( $(compgen -W "daily weekly monthly --period" -- "$cur") );;
20
25
  completion) COMPREPLY=( $(compgen -W "zsh bash" -- "$cur") );;
21
26
  esac
22
27
  }
@@ -31,9 +36,13 @@ _pleum() {
31
36
  compadd ${COMMANDS}; return
32
37
  fi
33
38
  case \${words[2]} in
34
- launch) compadd ${TOOLS};;
35
- wire|unwire) compadd claude codex;;
36
- run) compadd \${(f)"$(pleum models 2>/dev/null | awk 'NR>1{print \$1}')"};;
39
+ launch) compadd ${TOOLS} --model;;
40
+ wire|unwire) compadd claude codex --revoke-key;;
41
+ run|models) compadd \${(f)"$(pleum models 2>/dev/null | awk 'NR>1{print \$1}')"} --json --info;;
42
+ balance) compadd --switch;;
43
+ usage) compadd --by --days model provider api_key day tag user;;
44
+ lang) compadd en ko ja zh es;;
45
+ top) compadd daily weekly monthly --period;;
37
46
  completion) compadd zsh bash;;
38
47
  esac
39
48
  }
package/src/config.mjs CHANGED
@@ -194,11 +194,41 @@ export async function refreshAccessToken(cfg = readConfig()) {
194
194
  }
195
195
  }
196
196
 
197
- /** Bearer 요청 — 401이면 1회 refresh 후 재시도. */
197
+ /** 활성 크레딧/워크스페이스 컨텍스트 조회 (기본값 personal) */
198
+ export function getActiveContext(cfg = readConfig()) {
199
+ const ctx = cfg?.active_context;
200
+ if (ctx && ctx.type === "team" && ctx.org_id) {
201
+ return { type: "team", org_id: ctx.org_id, org_name: ctx.org_name || ctx.org_id };
202
+ }
203
+ return { type: "personal", org_id: null, org_name: null };
204
+ }
205
+
206
+ /** 활성 크레딧/워크스페이스 컨텍스트 저장 */
207
+ export function setActiveContext(context, cfg = readConfig()) {
208
+ const updated = { ...(cfg ?? {}) };
209
+ if (!context || context.type === "personal" || !context.org_id) {
210
+ updated.active_context = { type: "personal" };
211
+ } else {
212
+ updated.active_context = {
213
+ type: "team",
214
+ org_id: context.org_id,
215
+ org_name: context.org_name || null,
216
+ };
217
+ }
218
+ writeConfig(updated);
219
+ return updated.active_context;
220
+ }
221
+
222
+ /** Bearer 요청 — 401이면 1회 refresh 후 재시도. 활성 팀 컨텍스트가 있으면 X-Pleum-Org 첨부. */
198
223
  export async function authFetch(path, init = {}) {
199
224
  const token = await requireAuth();
200
225
  const url = path.startsWith("http") ? path : `${V1}${path.startsWith("/") ? path : `/${path}`}`;
201
- const headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` };
226
+ const ctx = getActiveContext();
227
+ const customHeaders = {};
228
+ if (ctx.type === "team" && ctx.org_id) {
229
+ customHeaders["X-Pleum-Org"] = ctx.org_id;
230
+ }
231
+ const headers = { ...customHeaders, ...(init.headers || {}), Authorization: `Bearer ${token}` };
202
232
  let res = await fetch(url, { ...init, headers });
203
233
  if (res.status !== 401) return res;
204
234
 
@@ -207,6 +237,7 @@ export async function authFetch(path, init = {}) {
207
237
  if (!refreshed) return res;
208
238
  return fetch(url, {
209
239
  ...init,
210
- headers: { ...(init.headers || {}), Authorization: `Bearer ${refreshed}` },
240
+ headers: { ...customHeaders, ...(init.headers || {}), Authorization: `Bearer ${refreshed}` },
211
241
  });
212
242
  }
243
+
package/src/health.mjs CHANGED
@@ -37,7 +37,7 @@ export function renderHealth({ root, health, error, ms, keyPrefix }, styler = st
37
37
  return lines.join("\n");
38
38
  }
39
39
 
40
- export async function health() {
40
+ export async function health({ exitOnError = true } = {}) {
41
41
  const cfg = readConfig();
42
42
  const keyPrefix = isLoggedIn(cfg)
43
43
  ? "oauth"
@@ -56,5 +56,5 @@ export async function health() {
56
56
  }
57
57
  const ms = Date.now() - t0;
58
58
  console.log(renderHealth({ root: ROOT, health: data, error, ms, keyPrefix }));
59
- if (error) process.exit(1);
59
+ if (error && exitOnError) process.exit(1);
60
60
  }
@@ -155,7 +155,8 @@ export async function select(items, {
155
155
  if (key.type === "ctrlc" || key.type === "escape") return finish(null);
156
156
  if (key.type === "enter") {
157
157
  const pool = matches(items, query);
158
- return finish(pool[cursor] ?? null);
158
+ const safeCursor = Math.max(0, Math.min(cursor, pool.length - 1));
159
+ return finish(pool[safeCursor] ?? null);
159
160
  }
160
161
  if (key.type === "up") cursor = Math.max(0, cursor - 1);
161
162
  else if (key.type === "down") cursor = Math.min(limit - 1, cursor + 1);
package/src/launch.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
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 } from "./config.mjs";
5
+ import { requireAuth, getActiveContext } from "./config.mjs";
6
6
  import {
7
7
  RECIPES,
8
8
  MANUAL,
@@ -95,6 +95,8 @@ export function launchHelp() {
95
95
 
96
96
  // exit:false면 process.exit 대신 자식 종료 코드를 반환 — 런처 메뉴의 실행 후 복귀용.
97
97
  export async function launch(tool, model, passthrough, { exit = true } = {}) {
98
+ recoverAgySettings();
99
+
98
100
  const key = await requireAuth();
99
101
  const resolved = resolveLaunchTool(tool);
100
102
  const r = RECIPES[resolved];
@@ -126,7 +128,41 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
126
128
  if (r.type === "crush-home") {
127
129
  env.CRUSH_GLOBAL_CONFIG = writeIsolatedHome("crush-home", "crush.json", crushJson(model));
128
130
  }
129
- if (r.type === "agy-settings") ensureAgySettings(model);
131
+
132
+ let restoreAgy = null;
133
+ if (r.type === "agy-settings") {
134
+ restoreAgy = setupAgySettings(model);
135
+ }
136
+
137
+ const cleanup = () => {
138
+ if (restoreAgy) {
139
+ restoreAgy();
140
+ restoreAgy = null;
141
+ }
142
+ };
143
+
144
+ const onSignal = () => {
145
+ cleanup();
146
+ if (exit) process.exit(130);
147
+ };
148
+
149
+ process.once("SIGINT", onSignal);
150
+ process.once("SIGTERM", onSignal);
151
+ process.once("SIGHUP", onSignal);
152
+ process.once("exit", cleanup);
153
+
154
+ const removeListeners = () => {
155
+ process.removeListener("SIGINT", onSignal);
156
+ process.removeListener("SIGTERM", onSignal);
157
+ process.removeListener("SIGHUP", onSignal);
158
+ process.removeListener("exit", cleanup);
159
+ };
160
+
161
+ const activeCtx = getActiveContext();
162
+ const creditMsg = activeCtx.type === "team"
163
+ ? `${t("Team")}: ${activeCtx.org_name}`
164
+ : t("Personal");
165
+ console.log(`ℹ ${t("Credit:")} ${creditMsg}`);
130
166
 
131
167
  const args = [...r.args(model), ...passthrough];
132
168
  if (r.note) console.log(`ℹ ${t(r.note)}`);
@@ -136,6 +172,8 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
136
172
  try {
137
173
  command = resolveLaunchCommand(r.bin, args);
138
174
  } catch (e) {
175
+ cleanup();
176
+ removeListeners();
139
177
  console.error(`✗ ${sanitizeTerminalText(e.message)}`);
140
178
  if (exit) process.exit(1);
141
179
  return 1;
@@ -148,6 +186,8 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
148
186
  shell: false,
149
187
  });
150
188
  child.on("error", (e) => {
189
+ cleanup();
190
+ removeListeners();
151
191
  if (e.code === "ENOENT") {
152
192
  console.error(`✗ ${t("'{bin}' not found. Install:\n {install}", { bin: r.bin, install: r.install })}`);
153
193
  if (exit) process.exit(127);
@@ -158,6 +198,8 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
158
198
  resolve(1);
159
199
  });
160
200
  child.on("exit", (code) => {
201
+ cleanup();
202
+ removeListeners();
161
203
  if (exit) process.exit(code ?? 0);
162
204
  resolve(code ?? 0);
163
205
  });
@@ -171,12 +213,142 @@ function writeIsolatedHome(name, filename, contents) {
171
213
  return home;
172
214
  }
173
215
 
174
- function ensureAgySettings(model) {
175
- const dir = join(homedir(), ".gemini", "antigravity-cli");
176
- const path = join(dir, "settings.json");
177
- mkdirSync(dir, { recursive: true, mode: 0o700 });
178
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
179
- writeFileSync(path, `${JSON.stringify(mergeAgySettings(existing, model), null, 2)}\n`, { mode: 0o600 });
216
+ export function getAgyPaths() {
217
+ const settingsPath = join(homedir(), ".gemini", "antigravity-cli", "settings.json");
218
+ const backupPath = join(homedir(), ".pleum", "agy-settings-backup.json");
219
+ return { settingsPath, backupPath };
220
+ }
221
+
222
+ export function setupAgySettings(model, {
223
+ paths = getAgyPaths(),
224
+ read = readFileSync,
225
+ write = writeFileSync,
226
+ exists = existsSync,
227
+ mkdir = mkdirSync,
228
+ rm = rmSync,
229
+ } = {}) {
230
+ const { settingsPath, backupPath } = paths;
231
+ const originalExists = exists(settingsPath);
232
+ let originalContent = null;
233
+ let parsed = null;
234
+
235
+ if (originalExists) {
236
+ try {
237
+ originalContent = read(settingsPath, "utf8");
238
+ parsed = JSON.parse(originalContent);
239
+ } catch {
240
+ parsed = null;
241
+ }
242
+ }
243
+
244
+ // 1. 비정상 종료 대비 백업 메타데이터 저장
245
+ try {
246
+ mkdir(dirname(backupPath), { recursive: true, mode: 0o700 });
247
+ write(
248
+ backupPath,
249
+ JSON.stringify(
250
+ {
251
+ settingsPath,
252
+ originalExists,
253
+ originalHadModelProvider: Boolean(parsed && "modelProvider" in parsed),
254
+ originalModelProvider: parsed?.modelProvider ?? null,
255
+ originalHadModel: Boolean(parsed && "model" in parsed),
256
+ originalModel: parsed?.model ?? null,
257
+ originalContent,
258
+ },
259
+ null,
260
+ 2,
261
+ ),
262
+ { mode: 0o600 },
263
+ );
264
+ } catch {}
265
+
266
+ // 2. 임시 설정 병합
267
+ mkdir(dirname(settingsPath), { recursive: true, mode: 0o700 });
268
+ write(settingsPath, `${JSON.stringify(mergeAgySettings(originalContent || "", model), null, 2)}\n`, { mode: 0o600 });
269
+
270
+ // 3. 종료 시 스마트 복구 클로저 (세션 중 사용자가 추가한 권한/워크스페이스 보존)
271
+ let restored = false;
272
+ return function restore() {
273
+ if (restored) return;
274
+ restored = true;
275
+ try {
276
+ if (!originalExists) {
277
+ if (exists(settingsPath)) rm(settingsPath, { force: true });
278
+ } else {
279
+ const currentContent = exists(settingsPath) ? read(settingsPath, "utf8") : null;
280
+ if (currentContent) {
281
+ try {
282
+ const currentObj = JSON.parse(currentContent);
283
+ if (parsed && "modelProvider" in parsed) {
284
+ currentObj.modelProvider = parsed.modelProvider;
285
+ } else {
286
+ delete currentObj.modelProvider;
287
+ }
288
+ if (parsed && "model" in parsed) {
289
+ currentObj.model = parsed.model;
290
+ } else {
291
+ delete currentObj.model;
292
+ }
293
+ write(settingsPath, `${JSON.stringify(currentObj, null, 2)}\n`, { mode: 0o600 });
294
+ } catch {
295
+ if (originalContent != null) write(settingsPath, originalContent, { mode: 0o600 });
296
+ }
297
+ } else if (originalContent != null) {
298
+ write(settingsPath, originalContent, { mode: 0o600 });
299
+ }
300
+ }
301
+ } catch {
302
+ // 복구 실패 시 최선 처리
303
+ } finally {
304
+ try {
305
+ if (exists(backupPath)) rm(backupPath, { force: true });
306
+ } catch {}
307
+ }
308
+ };
309
+ }
310
+
311
+ export function recoverAgySettings({
312
+ paths = getAgyPaths(),
313
+ read = readFileSync,
314
+ write = writeFileSync,
315
+ exists = existsSync,
316
+ rm = rmSync,
317
+ } = {}) {
318
+ const { backupPath } = paths;
319
+ if (!exists(backupPath)) return;
320
+ try {
321
+ const raw = read(backupPath, "utf8");
322
+ const backup = JSON.parse(raw);
323
+ const settingsPath = backup?.settingsPath;
324
+ if (settingsPath && exists(settingsPath)) {
325
+ if (!backup.originalExists) {
326
+ rm(settingsPath, { force: true });
327
+ } else {
328
+ const curContent = read(settingsPath, "utf8");
329
+ try {
330
+ const curObj = JSON.parse(curContent);
331
+ if (backup.originalHadModelProvider) {
332
+ curObj.modelProvider = backup.originalModelProvider;
333
+ } else {
334
+ delete curObj.modelProvider;
335
+ }
336
+ if (backup.originalHadModel) {
337
+ curObj.model = backup.originalModel;
338
+ } else {
339
+ delete curObj.model;
340
+ }
341
+ write(settingsPath, `${JSON.stringify(curObj, null, 2)}\n`, { mode: 0o600 });
342
+ } catch {
343
+ if (backup.originalContent != null) write(settingsPath, backup.originalContent, { mode: 0o600 });
344
+ }
345
+ }
346
+ }
347
+ } catch {} finally {
348
+ try {
349
+ if (exists(backupPath)) rm(backupPath, { force: true });
350
+ } catch {}
351
+ }
180
352
  }
181
353
 
182
354
  function printManual(tool, key) {
@@ -186,3 +358,4 @@ function printManual(tool, key) {
186
358
  console.log(m.snippet());
187
359
  console.log(`\n${t("2) Export the key and run:")}\n export PLEUM_API_KEY=${key}\n`);
188
360
  }
361
+
@@ -203,7 +203,7 @@ export const ES = {
203
203
  "Usage: pleum models <model-id> --info": "Uso: pleum models <model-id> --info",
204
204
  "Usage: pleum run <model> [\"prompt\"]\nModel list: pleum models": "Uso: pleum run <model> [\"prompt\"]\nLista de modelos: pleum models",
205
205
  "Use default (clear)": "Usar predeterminado (borrar)",
206
- "Your spend report (model|provider|api_key|day)": "Tu informe de gasto (model|provider|api_key|day)",
206
+ "Your spend report (model|provider|api_key|day|tag|user)": "Tu informe de gasto (model|provider|api_key|day|tag|user)",
207
207
  "by day": "por día",
208
208
  "by key": "por clave",
209
209
  "by model": "por modelo",
@@ -262,5 +262,22 @@ export const ES = {
262
262
  "✓ installed": "✓ instalado",
263
263
  "✗ not installed —": "✗ no instalado —",
264
264
  "Log out": "Cerrar sesión",
265
+ "Credit:": "Crédito:",
266
+ "Team": "Equipo",
267
+ "Personal": "Personal",
268
+ "Personal Credit": "Crédito personal",
269
+ "Credit workspace": "Espacio de crédito",
270
+ "Credit Balance:": "Saldo de crédito:",
271
+ "credits": "créditos",
272
+ "Select credit workspace": "Seleccionar espacio de crédito",
273
+ "Active credit set to: {name}": "Crédito activo configurado en: {name}",
274
+ "Failed to update credit context": "Error al actualizar el contexto de crédito",
275
+ "You are not a member of any team. Using personal credit.": "No eres miembro de ningún equipo. Usando crédito personal.",
276
+ "Run `pleum balance --switch` to switch credit workspace.": "Ejecuta `pleum balance --switch` para cambiar de espacio de crédito.",
277
+ "Example: pleum launch claude --model claude-sonnet-5": "Ejemplo: pleum launch claude --model claude-sonnet-5",
278
+ "supported (reasoning_effort: minimal|none|low|medium|high|xhigh|max)": "compatible (reasoning_effort: minimal|none|low|medium|high|xhigh|max)",
279
+ "by tag": "por etiqueta",
280
+ "by user": "por usuario",
281
+ "Failed to fetch balance ({status})": "Error al obtener el saldo ({status})",
265
282
  };
266
283
 
@@ -203,7 +203,7 @@ export const JA = {
203
203
  "Usage: pleum unwire [claude|codex] [--revoke-key]\n": "使い方: pleum unwire [claude|codex] [--revoke-key]\n",
204
204
  "Usage: pleum wire [claude|codex]\n": "使い方: pleum wire [claude|codex]\n",
205
205
  "Use default (clear)": "デフォルトを使用(解除)",
206
- "Your spend report (model|provider|api_key|day)": "支出レポート (model|provider|api_key|day)",
206
+ "Your spend report (model|provider|api_key|day|tag|user)": "支出レポート (model|provider|api_key|day|tag|user)",
207
207
  "by day": "日別",
208
208
  "by key": "キー別",
209
209
  "by model": "モデル別",
@@ -262,5 +262,22 @@ export const JA = {
262
262
  "✓ installed": "✓ インストール済み",
263
263
  "✗ not installed —": "✗ 未インストール —",
264
264
  "Log out": "ログアウト",
265
+ "Credit:": "クレジット:",
266
+ "Team": "チーム",
267
+ "Personal": "個人",
268
+ "Personal Credit": "個人クレジット",
269
+ "Credit workspace": "クレジットワークスペース",
270
+ "Credit Balance:": "クレジット残高:",
271
+ "credits": "クレジット",
272
+ "Select credit workspace": "クレジットワークスペースを選択",
273
+ "Active credit set to: {name}": "有効なクレジットを {name} に設定しました。",
274
+ "Failed to update credit context": "クレジットコンテキストの更新に失敗しました",
275
+ "You are not a member of any team. Using personal credit.": "所属しているチームがありません。個人クレジットを使用します。",
276
+ "Run `pleum balance --switch` to switch credit workspace.": "`pleum balance --switch` でクレジットワークスペースを変更できます。",
277
+ "Example: pleum launch claude --model claude-sonnet-5": "例: pleum launch claude --model claude-sonnet-5",
278
+ "supported (reasoning_effort: minimal|none|low|medium|high|xhigh|max)": "対応 (reasoning_effort: minimal|none|low|medium|high|xhigh|max)",
279
+ "by tag": "タグ別",
280
+ "by user": "ユーザー別",
281
+ "Failed to fetch balance ({status})": "残高の取得に失敗しました ({status})",
265
282
  };
266
283
 
@@ -263,7 +263,7 @@ export const KO = {
263
263
  "Model list (price, context included)": "모델 목록(가격·컨텍스트 포함)",
264
264
  "Model detail (price, perf, per-provider uptime)": "모델 상세(가격·성능·provider별 uptime)",
265
265
  "Model leaderboard · provider share (daily|weekly|monthly)": "모델 리더보드·제공자 점유율(daily|weekly|monthly)",
266
- "Your spend report (model|provider|api_key|day)": "내 지출 리포트(model|provider|api_key|day)",
266
+ "Your spend report (model|provider|api_key|day|tag|user)": "내 지출 리포트(model|provider|api_key|day|tag|user)",
267
267
  "Check gateway connectivity and login status": "게이트웨이 연결·로그인 상태 점검",
268
268
  "Show/change display language (en|ko|ja|zh|es)": "표시 언어 조회/변경 (en|ko|ja|zh|es)",
269
269
  "Print a shell completion script": "셸 자동완성 스크립트 출력",
@@ -325,5 +325,22 @@ export const KO = {
325
325
  "↑/↓ move • enter select • esc back": "↑/↓ 이동 • enter 선택 • esc 뒤로",
326
326
  "↑/↓ move • enter set model • esc back": "↑/↓ 이동 • enter 모델 설정 • esc 뒤로",
327
327
  "Log out": "로그아웃",
328
+ "Credit:": "크레딧:",
329
+ "Team": "팀",
330
+ "Personal": "개인",
331
+ "Personal Credit": "개인 크레딧",
332
+ "Credit workspace": "크레딧 워크스페이스",
333
+ "Credit Balance:": "크레딧 잔액:",
334
+ "credits": "크레딧",
335
+ "Select credit workspace": "크레딧 워크스페이스 선택",
336
+ "Active credit set to: {name}": "활성 크레딧이 {name}으로 설정되었습니다.",
337
+ "Failed to update credit context": "크레딧 컨텍스트 변경 실패",
338
+ "You are not a member of any team. Using personal credit.": "소속된 팀이 없습니다. 개인 크레딧을 사용합니다.",
339
+ "Run `pleum balance --switch` to switch credit workspace.": "`pleum balance --switch` 명령으로 크레딧 워크스페이스를 변경할 수 있습니다.",
340
+ "Example: pleum launch claude --model claude-sonnet-5": "예시: pleum launch claude --model claude-sonnet-5",
341
+ "supported (reasoning_effort: minimal|none|low|medium|high|xhigh|max)": "지원됨 (reasoning_effort: minimal|none|low|medium|high|xhigh|max)",
342
+ "by tag": "태그별",
343
+ "by user": "사용자별",
344
+ "Failed to fetch balance ({status})": "잔액 조회 실패 ({status})",
328
345
  };
329
346
 
@@ -203,7 +203,7 @@ export const ZH = {
203
203
  "Usage: pleum models <model-id> --info": "用法:pleum models <model-id> --info",
204
204
  "Usage: pleum run <model> [\"prompt\"]\nModel list: pleum models": "用法:pleum run <model> [\"prompt\"]\n模型列表:pleum models",
205
205
  "Use default (clear)": "使用默认值(清除)",
206
- "Your spend report (model|provider|api_key|day)": "你的花费报告(model|provider|api_key|day)",
206
+ "Your spend report (model|provider|api_key|day|tag|user)": "你的花费报告(model|provider|api_key|day|tag|user)",
207
207
  "by day": "按天",
208
208
  "by key": "按密钥",
209
209
  "by model": "按模型",
@@ -262,5 +262,22 @@ export const ZH = {
262
262
  "✓ installed": "✓ 已安装",
263
263
  "✗ not installed —": "✗ 未安装 —",
264
264
  "Log out": "登出",
265
+ "Credit:": "额度:",
266
+ "Team": "团队",
267
+ "Personal": "个人",
268
+ "Personal Credit": "个人额度",
269
+ "Credit workspace": "额度工作区",
270
+ "Credit Balance:": "额度余额:",
271
+ "credits": "额度",
272
+ "Select credit workspace": "选择额度工作区",
273
+ "Active credit set to: {name}": "已将有效额度设置为: {name}",
274
+ "Failed to update credit context": "更新额度上下文失败",
275
+ "You are not a member of any team. Using personal credit.": "您不属于任何团队。使用个人额度。",
276
+ "Run `pleum balance --switch` to switch credit workspace.": "运行 `pleum balance --switch` 可切换额度工作区。",
277
+ "Example: pleum launch claude --model claude-sonnet-5": "示例:pleum launch claude --model claude-sonnet-5",
278
+ "supported (reasoning_effort: minimal|none|low|medium|high|xhigh|max)": "支持 (reasoning_effort: minimal|none|low|medium|high|xhigh|max)",
279
+ "by tag": "按标签",
280
+ "by user": "按用户",
281
+ "Failed to fetch balance ({status})": "获取余额失败 ({status})",
265
282
  };
266
283
 
package/src/menu.mjs CHANGED
@@ -13,7 +13,7 @@ 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, touchSession } from "./config.mjs";
16
+ import { readConfig, writeConfig, isLoggedIn, touchSession, getActiveContext } from "./config.mjs";
17
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";
@@ -56,7 +56,7 @@ const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동
56
56
  // 최상위 메뉴 항목(순수 함수 — 테스트 대상). 에이전트는 없음 — "agents" 한 항목으로 서브메뉴 진입.
57
57
  // 로그인 항목도 없다 — CLI는 로그인 사용자용이라 mainMenu()가 진입 시점에 게이트로 처리하고,
58
58
  // 로그인/로그아웃 전환은 설정>계정이 소유한다.
59
- export function buildItems({ savedModels = {} }) {
59
+ export function buildItems({ savedModels = {}, activeContext = { type: "personal" } }) {
60
60
  const items = [];
61
61
  items.push({
62
62
  id: "chat",
@@ -65,11 +65,14 @@ export function buildItems({ savedModels = {} }) {
65
65
  desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
66
66
  });
67
67
  items.push({ id: "agents", title: t("Run an agent"), desc: t("Pick a coding agent to run through PleumRouter") });
68
+ const contextDesc = activeContext?.type === "team" && activeContext.org_name
69
+ ? `${t("Team")}: ${activeContext.org_name}`
70
+ : t("Personal");
68
71
  items.push(
69
72
  { id: "models", title: t("Model list"), desc: t("All models — price and context included") },
70
73
  { id: "top", title: t("Leaderboard"), desc: t("Model rankings · provider share") },
71
74
  { id: "usage", title: t("Usage report"), desc: t("Spend over the last 30 days") },
72
- { id: "balance", title: t("Balance"), desc: t("Check your credit balance") },
75
+ { id: "balance", title: t("Balance"), desc: `${t("Check your credit balance")} · ${contextDesc}` },
73
76
  { id: "health", title: t("Health check"), desc: t("Gateway connectivity · login status") },
74
77
  { id: "settings", title: t("Settings"), desc: t("Language, default models, account, updates") },
75
78
  );
@@ -275,7 +278,10 @@ export async function runList(items, { header, footer, initialCursor = 0 } = {})
275
278
  async function menuSelect() {
276
279
  const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
277
280
  const cfg = readConfig();
278
- const items = buildItems({ savedModels: cfg?.launch_models ?? {} });
281
+ const items = buildItems({
282
+ savedModels: cfg?.launch_models ?? {},
283
+ activeContext: getActiveContext(cfg),
284
+ });
279
285
  const last = cfg?.last_selection;
280
286
  const initialCursor = last ? Math.max(0, items.findIndex((it) => it.id === last)) : 0;
281
287
  return runList(items, {
@@ -368,19 +374,39 @@ export async function mainMenu() {
368
374
  return true;
369
375
  }
370
376
  case "models":
371
- await listModels([]);
377
+ try {
378
+ await listModels([]);
379
+ } catch (e) {
380
+ console.error(`✗ ${sanitizeTerminalText(e.message)}`);
381
+ }
372
382
  continue;
373
383
  case "top":
374
- await top([]);
384
+ try {
385
+ await top([]);
386
+ } catch (e) {
387
+ console.error(`✗ ${sanitizeTerminalText(e.message)}`);
388
+ }
375
389
  continue;
376
390
  case "usage":
377
- await usage([]);
391
+ try {
392
+ await usage([]);
393
+ } catch (e) {
394
+ console.error(`✗ ${sanitizeTerminalText(e.message)}`);
395
+ }
378
396
  continue;
379
397
  case "balance":
380
- await balance();
398
+ try {
399
+ await balance({ interactive: true, exitOnError: false });
400
+ } catch (e) {
401
+ console.error(`✗ ${sanitizeTerminalText(e.message)}`);
402
+ }
381
403
  continue;
382
404
  case "health":
383
- await health();
405
+ try {
406
+ await health({ exitOnError: false });
407
+ } catch (e) {
408
+ console.error(`✗ ${sanitizeTerminalText(e.message)}`);
409
+ }
384
410
  continue;
385
411
  case "settings": {
386
412
  const r = await runSettings();
package/src/models.mjs CHANGED
@@ -45,7 +45,7 @@ export function fmtPrice(model) {
45
45
  if (v == null) return "—";
46
46
  const n = Number(v) * 1_000_000;
47
47
  if (!Number.isFinite(n)) return "—";
48
- return sym === "₩" ? `${Math.round(n).toLocaleString("en-US")} 크레딧` : `$${n.toFixed(2)}`;
48
+ return sym === "₩" ? `${Math.round(n).toLocaleString("en-US")} ${t("credits")}` : `$${n.toFixed(2)}`;
49
49
  };
50
50
  const kr = model.pricing_krw || {};
51
51
  if (kr.prompt != null || kr.completion != null) return [per1m(kr.prompt, "₩"), per1m(kr.completion, "₩")];
package/src/run.mjs CHANGED
@@ -165,7 +165,7 @@ export function buildContent(text, { attachments = [], lstat = lstatSync, read =
165
165
  // 응답 후 한 줄 통계(순수 함수 — 테스트 대상). verbose일 때만 토큰/속도 표시.
166
166
  export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
167
167
  const bits = [];
168
- if (cost != null && cost !== "") bits.push(`${fmtCost(cost)} 크레딧`);
168
+ if (cost != null && cost !== "") bits.push(`${fmtCost(cost)} ${t("credits")}`);
169
169
  if (verbose && usage) {
170
170
  bits.push(`↑${sanitizeTerminalText(usage.prompt_tokens ?? "?")} ↓${sanitizeTerminalText(usage.completion_tokens ?? "?")} tok`);
171
171
  if (tokPerSec) bits.push(`${tokPerSec.toFixed(1)} tok/s`);
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 } 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";
@@ -16,13 +16,18 @@ import { login, logout } from "./login.mjs";
16
16
  import { upgrade } from "./upgrade.mjs";
17
17
  import { completion } from "./completion.mjs";
18
18
  import { lang } from "./lang.mjs";
19
+ import { switchCreditWorkspace } from "./balance.mjs";
19
20
  import { runList, saveModel } from "./menu.mjs";
20
21
 
21
22
  // Settings 서브메뉴 항목(순수 함수 — 테스트 대상).
22
- export function buildSettingsItems({ lang, loggedIn, keyPrefix, version }) {
23
+ export function buildSettingsItems({ lang, loggedIn, keyPrefix, version, activeContext = { type: "personal" } }) {
24
+ const contextDesc = activeContext?.type === "team" && activeContext.org_name
25
+ ? `${t("Team")}: ${activeContext.org_name}`
26
+ : t("Personal Credit");
23
27
  return [
24
28
  { id: "settings:lang", title: t("Language"), desc: `${t("current")}: ${LANGS[lang] ?? lang}` },
25
29
  { id: "settings:models", title: t("Default models"), desc: t("Set the default model used per agent") },
30
+ { id: "settings:context", title: t("Credit workspace"), desc: contextDesc },
26
31
  {
27
32
  id: "settings:account",
28
33
  // 제목 자체가 다음 동작을 말한다 — 로그인 상태에서 Enter는 로그아웃이다.
@@ -73,6 +78,7 @@ async function settingsSelect() {
73
78
  loggedIn: isLoggedIn(cfg),
74
79
  keyPrefix: cfg?.access_token ? "oauth" : cfg?.key ? cfg.key.slice(0, 8) : null,
75
80
  version: readVersion(),
81
+ activeContext: getActiveContext(cfg),
76
82
  });
77
83
  return runList(items, {
78
84
  header: style.bold(style.plum(t("Settings"))),
@@ -152,6 +158,9 @@ export async function runSettings() {
152
158
  if (r === "quit") return "quit";
153
159
  continue;
154
160
  }
161
+ case "settings:context":
162
+ await switchCreditWorkspace();
163
+ continue;
155
164
  case "settings:account":
156
165
  await runAccount();
157
166
  continue;
package/src/usage.mjs CHANGED
@@ -5,7 +5,7 @@ import { authFetch } from "./config.mjs";
5
5
  import { style, padDisplay, barChart, divider } from "./ansi.mjs";
6
6
  import { t } from "./i18n.mjs";
7
7
 
8
- const GROUPS = ["model", "provider", "api_key", "day"];
8
+ const GROUPS = ["model", "provider", "api_key", "day", "tag", "user"];
9
9
 
10
10
  export function parseUsageArgs(rest = []) {
11
11
  let by = "model";
@@ -21,12 +21,19 @@ export function parseUsageArgs(rest = []) {
21
21
 
22
22
  // 원장 눈금 0.1 — Math.round로 자르면 소액 사용이 0으로 보인다.
23
23
  const fmtKrw = (n) =>
24
- `${Number(n).toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} 크레딧`;
24
+ `${Number(n).toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} ${t("credits")}`;
25
25
 
26
26
  // report 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
27
27
  export function renderUsage(data, styler = style) {
28
28
  const lines = [];
29
- const byLabel = { model: t("by model"), provider: t("by provider"), api_key: t("by key"), day: t("by day") }[data.group_by] || data.group_by;
29
+ const byLabel = {
30
+ model: t("by model"),
31
+ provider: t("by provider"),
32
+ api_key: t("by key"),
33
+ day: t("by day"),
34
+ tag: t("by tag"),
35
+ user: t("by user"),
36
+ }[data.group_by] || data.group_by;
30
37
  lines.push(`${styler.bold(styler.plum(t("Usage")))} ${styler.dim(`· ${byLabel} · ${t("last {days} days", { days: data.days })}`)}`);
31
38
  lines.push(divider(undefined, styler));
32
39
  const items = data.items || [];