pleumrouter 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.mjs CHANGED
@@ -1,17 +1,44 @@
1
1
  import { createInterface } from "node:readline";
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
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";
5
- import { fetchModels, formatTable, matchModels } from "./models.mjs";
6
- import { style, divider } from "./ansi.mjs";
4
+ import { V1, requireAuth, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
5
+ import { fetchModels, formatTable, matchModels, formatModelLabel, safeModelText } from "./models.mjs";
6
+ import { style, divider, sanitizeTerminalText } from "./ansi.mjs";
7
7
  import { createMarkdownStream } from "./markdown.mjs";
8
8
  import { spinner, select } from "./interactive.mjs";
9
9
  import { t } from "./i18n.mjs";
10
10
 
11
- const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
11
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
12
+ const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
13
+ const GIF87A_MAGIC = Buffer.from("GIF87a");
14
+ const GIF89A_MAGIC = Buffer.from("GIF89a");
15
+ const RIFF_MAGIC = Buffer.from("RIFF");
16
+ const WEBP_MAGIC = Buffer.from("WEBP");
17
+ const BMP_MAGIC = Buffer.from("BM");
18
+ const startsWith = (bytes, magic, offset = 0) =>
19
+ bytes.length >= offset + magic.length && bytes.subarray(offset, offset + magic.length).equals(magic);
20
+
21
+ const IMAGE_FORMATS = {
22
+ png: { mime: "image/png", matches: (bytes) => startsWith(bytes, PNG_MAGIC) },
23
+ jpg: { mime: "image/jpeg", matches: (bytes) => startsWith(bytes, JPEG_MAGIC) },
24
+ jpeg: { mime: "image/jpeg", matches: (bytes) => startsWith(bytes, JPEG_MAGIC) },
25
+ gif: {
26
+ mime: "image/gif",
27
+ matches: (bytes) => startsWith(bytes, GIF87A_MAGIC) || startsWith(bytes, GIF89A_MAGIC),
28
+ },
29
+ webp: {
30
+ mime: "image/webp",
31
+ matches: (bytes) => startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8),
32
+ },
33
+ bmp: { mime: "image/bmp", matches: (bytes) => startsWith(bytes, BMP_MAGIC) },
34
+ };
35
+
36
+ export const MAX_IMAGE_ATTACHMENT_BYTES = 5 * 1024 * 1024;
37
+ const MAX_IMAGE_ATTACHMENTS = 4;
38
+ const MAX_TOTAL_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
12
39
 
13
40
  // `pleum run [model] ["프롬프트"...] [--verbose] [--skip-cache] [--tag a,b]
14
- // [--reasoning-effort LEVEL] [--service-tier TIER] [--reasoning-mode MODE]`
41
+ // [--attach image] [--reasoning-effort LEVEL] [--service-tier TIER] [--reasoning-mode MODE]`
15
42
  // 인자 파싱(순수 함수 — 테스트 대상).
16
43
  const REASONING_EFFORTS = new Set(["minimal", "none", "low", "medium", "high", "xhigh", "max"]);
17
44
  const SERVICE_TIERS = new Set(["auto", "default", "flex", "background", "priority"]);
@@ -24,6 +51,7 @@ export function parseRunArgs(rest = []) {
24
51
  let reasoningEffort = null;
25
52
  let serviceTier = null;
26
53
  let reasoningMode = null;
54
+ const attachments = [];
27
55
  const pos = [];
28
56
  for (let i = 0; i < rest.length; i++) {
29
57
  const a = rest[i];
@@ -53,6 +81,11 @@ export function parseRunArgs(rest = []) {
53
81
  reasoningMode = v;
54
82
  i++;
55
83
  }
84
+ } else if (a === "--attach" || a === "-a") {
85
+ const path = rest[i + 1];
86
+ if (typeof path !== "string" || !path) throw new Error("--attach requires an image path");
87
+ attachments.push(path);
88
+ i++;
56
89
  } else pos.push(a);
57
90
  }
58
91
  return {
@@ -64,19 +97,67 @@ export function parseRunArgs(rest = []) {
64
97
  reasoningEffort,
65
98
  serviceTier,
66
99
  reasoningMode,
100
+ attachments,
67
101
  };
68
102
  }
69
103
 
70
- // 입력에서 존재하는 이미지 파일 경로를 찾아 멀티모달 content로 만든다(없으면 문자열 그대로).
71
- // exists/read 주입 가능 — 테스트용.
72
- export function buildContent(text, exists = existsSync, read = readFileSync) {
73
- const imgs = text.split(/\s+/).filter((t) => IMG_EXT.test(t) && exists(t));
74
- if (!imgs.length) return text;
104
+ function imageFormat(path) {
105
+ const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
106
+ return ext ? IMAGE_FORMATS[ext] : null;
107
+ }
108
+
109
+ function inlineAttachmentPaths(text) {
110
+ // REPL에서도 안전하게 쓸 수 있도록 토큰 맨 앞 @만 명시 첨부 문법으로 해석한다.
111
+ // 일반적인 ./photo.png 문구와 @mention은 그대로 텍스트로 남는다.
112
+ return text
113
+ .split(/\s+/)
114
+ .filter((token) => token.startsWith("@") && imageFormat(token.slice(1)))
115
+ .map((token) => token.slice(1));
116
+ }
117
+
118
+ function imagePart(path, { lstat, read }) {
119
+ const format = imageFormat(path);
120
+ if (!format) throw new Error(`Unsupported image attachment: ${path}`);
121
+
122
+ let info;
123
+ try {
124
+ info = lstat(path);
125
+ } catch {
126
+ throw new Error(`Image attachment not found: ${path}`);
127
+ }
128
+ if (info.isSymbolicLink()) throw new Error(`Refusing symlink image attachment: ${path}`);
129
+ if (!info.isFile()) throw new Error(`Image attachment is not a regular file: ${path}`);
130
+ if (!Number.isSafeInteger(info.size) || info.size < 1) throw new Error(`Image attachment has an invalid size: ${path}`);
131
+ if (info.size > MAX_IMAGE_ATTACHMENT_BYTES) {
132
+ throw new Error(`Image attachment is too large (max ${MAX_IMAGE_ATTACHMENT_BYTES} bytes): ${path}`);
133
+ }
134
+
135
+ const bytes = Buffer.from(read(path));
136
+ if (bytes.length !== info.size) throw new Error(`Image attachment changed while reading: ${path}`);
137
+ if (bytes.length > MAX_IMAGE_ATTACHMENT_BYTES) {
138
+ throw new Error(`Image attachment is too large (max ${MAX_IMAGE_ATTACHMENT_BYTES} bytes): ${path}`);
139
+ }
140
+ if (!format.matches(bytes)) throw new Error(`Image attachment does not match its file extension: ${path}`);
141
+ return { type: "image_url", image_url: { url: `data:${format.mime};base64,${bytes.toString("base64")}` }, size: bytes.length };
142
+ }
143
+
144
+ // Local files are uploaded only when explicitly selected with --attach or an @path REPL token.
145
+ // lstat/read injection keeps the filesystem checks focused and regression-testable.
146
+ export function buildContent(text, { attachments = [], lstat = lstatSync, read = readFileSync } = {}) {
147
+ const paths = [...new Set([...attachments, ...inlineAttachmentPaths(text)])];
148
+ if (!paths.length) return text;
149
+ if (paths.length > MAX_IMAGE_ATTACHMENTS) {
150
+ throw new Error(`Too many image attachments (max ${MAX_IMAGE_ATTACHMENTS})`);
151
+ }
75
152
  const parts = [{ type: "text", text }];
76
- for (const p of imgs) {
77
- const ext = p.match(IMG_EXT)[1].toLowerCase().replace("jpg", "jpeg");
78
- const b64 = Buffer.from(read(p)).toString("base64");
79
- parts.push({ type: "image_url", image_url: { url: `data:image/${ext};base64,${b64}` } });
153
+ let totalSize = 0;
154
+ for (const path of paths) {
155
+ const { size, ...part } = imagePart(path, { lstat, read });
156
+ totalSize += size;
157
+ if (totalSize > MAX_TOTAL_IMAGE_ATTACHMENT_BYTES) {
158
+ throw new Error(`Total image attachments are too large (max ${MAX_TOTAL_IMAGE_ATTACHMENT_BYTES} bytes)`);
159
+ }
160
+ parts.push(part);
80
161
  }
81
162
  return parts;
82
163
  }
@@ -86,17 +167,17 @@ export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
86
167
  const bits = [];
87
168
  if (cost != null && cost !== "") bits.push(`₩${fmtCost(cost)}`);
88
169
  if (verbose && usage) {
89
- bits.push(`↑${usage.prompt_tokens ?? "?"} ↓${usage.completion_tokens ?? "?"} tok`);
170
+ bits.push(`↑${sanitizeTerminalText(usage.prompt_tokens ?? "?")} ↓${sanitizeTerminalText(usage.completion_tokens ?? "?")} tok`);
90
171
  if (tokPerSec) bits.push(`${tokPerSec.toFixed(1)} tok/s`);
91
172
  }
92
- if (verbose) bits.push(`${ms}ms`);
173
+ if (verbose) bits.push(`${sanitizeTerminalText(ms)}ms`);
93
174
  return bits.length ? ` (${bits.join(" · ")})` : "";
94
175
  }
95
176
 
96
177
  // 비용 표시: 숫자면 소수 2자리까지(후행 0 제거), 문자열은 그대로.
97
178
  function fmtCost(v) {
98
179
  const n = Number(v);
99
- return Number.isFinite(n) ? String(+n.toFixed(2)) : String(v);
180
+ return Number.isFinite(n) ? String(+n.toFixed(2)) : sanitizeTerminalText(v);
100
181
  }
101
182
 
102
183
  function isAbort(e) {
@@ -114,11 +195,12 @@ export async function run(rest) {
114
195
  reasoningEffort,
115
196
  serviceTier,
116
197
  reasoningMode,
198
+ attachments,
117
199
  } = parseRunArgs(args);
118
200
  let model = argModel;
119
201
  const piped = await readStdin();
120
- const oneShot = Boolean(inlinePrompt || piped);
121
- const key = requireKey();
202
+ const oneShot = Boolean(inlinePrompt || piped || attachments.length);
203
+ const key = await requireAuth();
122
204
  const callOpts = { tags, skipCache, reasoningEffort, serviceTier, reasoningMode };
123
205
 
124
206
  if (!model && !oneShot) {
@@ -137,17 +219,16 @@ export async function run(rest) {
137
219
 
138
220
  if (oneShot) {
139
221
  const prompt = [inlinePrompt, piped].filter(Boolean).join("\n\n");
140
- const content = buildContent(prompt);
141
- if (Array.isArray(content)) {
142
- const n = content.filter((p) => p.type === "image_url").length;
143
- // 프롬프트 내 로컬 이미지 경로를 자동 첨부·업송하므로 조용히 올리지 않고 명시 고지한다(M8).
144
- console.error(`ℹ ${t("Attaching and uploading {n} local image(s) from the prompt.", { n })}`);
145
- }
146
- const messages = [{ role: "user", content }];
147
222
  try {
223
+ const content = buildContent(prompt, { attachments });
224
+ if (Array.isArray(content)) {
225
+ const n = content.filter((p) => p.type === "image_url").length;
226
+ console.error(`ℹ ${t("Attaching and uploading {n} explicit local image(s).", { n })}`);
227
+ }
228
+ const messages = [{ role: "user", content }];
148
229
  await streamChat(key, model, messages, { verbose, quiet: true, ...callOpts });
149
230
  } catch (e) {
150
- console.error(`${style.red("✗")} ${e.message}`);
231
+ console.error(`${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
151
232
  process.exit(1);
152
233
  }
153
234
  process.exit(0);
@@ -174,11 +255,14 @@ async function repl(key, initialModel, initialVerbose, {
174
255
  let rl;
175
256
 
176
257
  const send = async (text) => {
177
- messages.push({ role: "user", content: buildContent(text) });
178
- const req = system ? [{ role: "system", content: system }, ...messages] : messages;
179
- const ac = new AbortController();
180
- currentAbort = ac;
258
+ let added = false;
181
259
  try {
260
+ const content = buildContent(text);
261
+ messages.push({ role: "user", content });
262
+ added = true;
263
+ const req = system ? [{ role: "system", content: system }, ...messages] : messages;
264
+ const ac = new AbortController();
265
+ currentAbort = ac;
182
266
  const reply = await streamChat(key, model, req, {
183
267
  verbose,
184
268
  signal: ac.signal,
@@ -191,8 +275,8 @@ async function repl(key, initialModel, initialVerbose, {
191
275
  if (reply) messages.push({ role: "assistant", content: reply });
192
276
  else messages.pop(); // 응답 없음(즉시 중단 등) — user 턴 되돌림
193
277
  } catch (e) {
194
- console.error(`\n${style.red("✗")} ${e.message}`);
195
- messages.pop(); // 실패한 user 턴 제거
278
+ console.error(`\n${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
279
+ if (added) messages.pop(); // 실패한 user 턴 제거
196
280
  } finally {
197
281
  currentAbort = null;
198
282
  }
@@ -310,7 +394,7 @@ async function repl(key, initialModel, initialVerbose, {
310
394
  };
311
395
 
312
396
  makeRl(loadHistory());
313
- console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
397
+ console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${safeModelText(model)}`);
314
398
  console.log(style.dim(t('/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation')));
315
399
  console.log(divider());
316
400
  rl.prompt();
@@ -329,7 +413,7 @@ const SLASH_HELP = `Commands:
329
413
  /verbose toggle token/speed stats
330
414
  /exit, /quit quit
331
415
  Multi-line input: a line with """ → body → a line with """
332
- Images: include an image file path right in the message (e.g. describe this ./shot.png)`;
416
+ Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)`;
333
417
 
334
418
  async function handleSlash(text, ctx) {
335
419
  const [cmd] = text.slice(1).split(/\s+/);
@@ -354,17 +438,17 @@ async function handleSlash(text, ctx) {
354
438
  case "model":
355
439
  if (arg) {
356
440
  ctx.model = arg;
357
- console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(arg)}`);
441
+ console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(safeModelText(arg))}`);
358
442
  } else if (ctx.pickModel) {
359
443
  const picked = await ctx.pickModel();
360
444
  if (picked) {
361
445
  ctx.model = picked;
362
- console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(picked)}`);
446
+ console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(safeModelText(picked))}`);
363
447
  } else {
364
- console.log(`${t("Current model:")} ${style.plum(ctx.model)}`);
448
+ console.log(`${t("Current model:")} ${style.plum(safeModelText(ctx.model))}`);
365
449
  }
366
450
  } else {
367
- console.log(`${t("Current model:")} ${ctx.model}\n${t("switch: /model <id> · list: /models")}`);
451
+ console.log(`${t("Current model:")} ${safeModelText(ctx.model)}\n${t("switch: /model <id> · list: /models")}`);
368
452
  }
369
453
  break;
370
454
  case "models":
@@ -373,7 +457,7 @@ async function handleSlash(text, ctx) {
373
457
  const rows = matchModels(models, arg);
374
458
  console.log(rows.length ? formatTable(rows) : t("No matching models"));
375
459
  } catch (e) {
376
- console.error(`${style.red("✗")} ${e.message}`);
460
+ console.error(`${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
377
461
  }
378
462
  break;
379
463
  case "save":
@@ -381,13 +465,13 @@ async function handleSlash(text, ctx) {
381
465
  const name = saveSession(arg || "last", ctx.snapshot());
382
466
  console.log(`${style.green("✓")} ${t("Session saved:")} ${name}`);
383
467
  } catch (e) {
384
- console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: e.message })}`);
468
+ console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: sanitizeTerminalText(e.message) })}`);
385
469
  }
386
470
  break;
387
471
  case "load":
388
472
  try {
389
473
  ctx.restore(loadSession(arg || "last"));
390
- console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: ctx.model })}`);
474
+ console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: safeModelText(ctx.model) })}`);
391
475
  } catch {
392
476
  console.error(`${style.red("✗")} ${t("No such session: {name}", { name: arg || "last" })}`);
393
477
  }
@@ -415,7 +499,7 @@ export async function streamChat(key, model, messages, {
415
499
  fetchImpl = fetch,
416
500
  } = {}) {
417
501
  const t0 = Date.now();
418
- const sp = spinner(t("{model} thinking…", { model }));
502
+ const sp = spinner(t("{model} thinking…", { model: sanitizeTerminalText(model) }));
419
503
  let res;
420
504
  try {
421
505
  // 게이트웨이 패리티(0095): X-Pleum-Skip-Cache 헤더 + bounded tags 본문.
@@ -443,7 +527,7 @@ export async function streamChat(key, model, messages, {
443
527
  if (!res.ok) {
444
528
  sp.stop();
445
529
  const body = await res.text().catch(() => "");
446
- throw new Error(`${res.status} ${body.slice(0, 300)}`);
530
+ throw new Error(`${res.status} ${sanitizeTerminalText(body.slice(0, 300))}`);
447
531
  }
448
532
 
449
533
  const md = createMarkdownStream({ prefix: quiet ? "" : `${style.plum("bot")}${style.dim(">")} ` });
@@ -470,12 +554,14 @@ export async function streamChat(key, model, messages, {
470
554
  if (j.cost) cost = j.cost; // 마지막 청크 — {usd, krw, fx_rate, markup_rate}
471
555
  const delta = j.choices?.[0]?.delta?.content;
472
556
  if (delta) {
557
+ const safeDelta = sanitizeTerminalText(delta);
558
+ if (!safeDelta) continue;
473
559
  if (!firstAt) {
474
560
  firstAt = Date.now();
475
561
  sp.stop();
476
562
  }
477
- md.push(delta);
478
- full += delta;
563
+ md.push(safeDelta);
564
+ full += safeDelta;
479
565
  }
480
566
  } catch {
481
567
  // 부분 청크 — 무시
@@ -512,14 +598,14 @@ async function pickModel() {
512
598
  try {
513
599
  models = await fetchModels();
514
600
  } catch (e) {
515
- console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
601
+ console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
516
602
  return null;
517
603
  }
518
604
  if (!models.length) return null;
519
605
  if (process.stdin.isTTY && process.stdout.isTTY) {
520
606
  return select(models, {
521
607
  message: t("Select a model"),
522
- formatItem: (m) => `${m.id}${m.display_name ? ` ${style.dim(m.display_name)}` : ""}`,
608
+ formatItem: (m) => formatModelLabel(m),
523
609
  matches: matchModels,
524
610
  limit: 12,
525
611
  }).then((m) => m?.id ?? null);
@@ -536,7 +622,7 @@ async function pickModelNumbered(models) {
536
622
  for (;;) {
537
623
  const shown = pool.slice(0, 30);
538
624
  shown.forEach((m, i) =>
539
- console.log(` ${String(i + 1).padStart(2)}. ${m.id}${m.display_name ? ` (${m.display_name})` : ""}`),
625
+ console.log(` ${String(i + 1).padStart(2)}. ${formatModelLabel(m, { dim: (s) => `(${s})` })}`),
540
626
  );
541
627
  if (pool.length > shown.length) console.log(` ${t("… {n} more — narrow with a search term", { n: pool.length - shown.length })}`);
542
628
  const ans = (await ask(`\n${t("Choose a number, or search (Enter to cancel): ")}`)).trim();
package/src/settings.mjs CHANGED
@@ -6,12 +6,12 @@
6
6
  // 즉시 참조하면 순환의 평가 순서에 따라 깨질 수 있다).
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
- import { style } from "./ansi.mjs";
9
+ import { style, sanitizeTerminalText } from "./ansi.mjs";
10
10
  import { select, confirm } from "./interactive.mjs";
11
11
  import { readConfig, writeConfig, clearCredentials } from "./config.mjs";
12
12
  import { getLocale, LANGS, t } from "./i18n.mjs";
13
13
  import { RECIPES } from "./recipes.mjs";
14
- import { fetchModels, matchModels } from "./models.mjs";
14
+ import { fetchModels, matchModels, formatModelLabel } from "./models.mjs";
15
15
  import { login } from "./login.mjs";
16
16
  import { upgrade } from "./upgrade.mjs";
17
17
  import { completion } from "./completion.mjs";
@@ -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, {
@@ -82,12 +86,12 @@ async function pickDefaultModelFor(item) {
82
86
  try {
83
87
  models = await fetchModels();
84
88
  } catch (e) {
85
- console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
89
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
86
90
  return undefined;
87
91
  }
88
92
  const picked = await select([CLEAR_OPTION, ...models], {
89
93
  message: `${item.title} — ${t("select a model")}`,
90
- formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) : `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`),
94
+ formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) : formatModelLabel(x)),
91
95
  matches: matchModels, // 쿼리 입력 시 CLEAR_OPTION은 자연히 필터링됨(id/display_name 불일치) — 별도 처리 불요
92
96
  limit: 12,
93
97
  });
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)."))}`,