pleumrouter 0.5.5 → 0.5.6

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
@@ -33,7 +33,7 @@ cat error.log | npx pleumrouter run gpt-5.4-mini "원인은?" # 파이프
33
33
  | `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
34
34
  | `pleum logout` | 저장된 API 키만 삭제(언어·기본 모델 등 나머지 설정은 유지) |
35
35
  | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
36
- | `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
36
+ | `pleum run [model] ["프롬프트"] [--attach image]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. 이미지 업로드는 명시적으로 `--attach ./shot.png`(1회 실행) 또는 REPL의 `@./shot.png`를 사용한다. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
37
37
  | `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
38
38
  | `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
39
39
  | `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
@@ -64,7 +64,7 @@ cat error.log | npx pleumrouter run gpt-5.4-mini "원인은?" # 파이프
64
64
  - 응답 후 비용이 현지 통화(한국 ₩)로 표시(스트리밍 마지막 청크 기준)
65
65
  - **Ctrl+C**: 생성 중엔 1회로 중단(대화 유지), 대기 중엔 2초 내 2회로 종료
66
66
  - 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
67
- - 이미지: 메시지에 이미지 파일 경로를 그대로 포함 (예: `이 그림 설명해줘 ./shot.png`)
67
+ - 이미지: 명시적으로 첨부 — REPL에서는 `이 그림 설명해줘 @./shot.png`, 1회 실행에서는 `--attach ./shot.png`. 일반 프롬프트의 `./shot.png` 텍스트는 업로드되지 않는다. 첨부는 일반 파일·이미지 magic bytes·5 MiB/장(총 10 MiB)을 확인하고 심볼릭 링크는 거부한다.
68
68
  - `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
69
69
  - 색상 끄기: `NO_COLOR=1` (비TTY·파이프에선 자동 무색)
70
70
 
@@ -78,7 +78,7 @@ env(또는 격리된 `CODEX_HOME`/임시 설정 문자열)만 세팅하고 바
78
78
 
79
79
  ## 환경변수
80
80
 
81
- - `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://router.pleum.ai`). 셀프호스트/스테이징용.
81
+ - `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://apirouter.pleum.ai`). 셀프호스트/스테이징용.
82
82
 
83
83
  ## 개발
84
84
 
package/bin/pleum.mjs CHANGED
@@ -15,6 +15,7 @@ import { mainMenu } from "../src/menu.mjs";
15
15
  import { lang } from "../src/lang.mjs";
16
16
  import { RECIPES } from "../src/recipes.mjs";
17
17
  import { t } from "../src/i18n.mjs";
18
+ import { sanitizeTerminalText } from "../src/ansi.mjs";
18
19
 
19
20
  // 명령 구문(왼쪽)은 로케일과 무관 — 설명(오른쪽)만 번역, 폭은 가장 긴 구문 기준으로 계산.
20
21
  function buildHelp() {
@@ -26,7 +27,7 @@ function buildHelp() {
26
27
  ["pleum launch [tool] [--model id]", t("Run a coding agent against PleumRouter")],
27
28
  ["", `(${Object.keys(RECIPES).join(" · ")})`],
28
29
  ["", t("omit the tool to see the supported list and install status")],
29
- ['pleum run [model] ["prompt"]', t("Terminal chat (REPL) / one-shot / pipe")],
30
+ ['pleum run [model] ["prompt"] [--attach image]', t("Terminal chat (REPL) / one-shot / pipe")],
30
31
  ["", t("omit the model for an arrow-key picker; /help lists commands")],
31
32
  ["pleum models [query] [--json]", t("Model list (price, context included)")],
32
33
  ["pleum models <id> --info", t("Model detail (price, perf, per-provider uptime)")],
@@ -146,6 +147,6 @@ try {
146
147
  process.exit(1);
147
148
  }
148
149
  } catch (e) {
149
- console.error(`✗ ${e.message}`);
150
+ console.error(`✗ ${sanitizeTerminalText(e.message)}`);
150
151
  process.exit(1);
151
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ansi.mjs CHANGED
@@ -55,6 +55,18 @@ export function stripAnsi(s) {
55
55
  .replace(/\x1b\[[0-9;<]*[A-Za-z]/g, "");
56
56
  }
57
57
 
58
+ // 원격 응답/메타데이터는 신뢰하지 않는다. 터미널 상태를 바꾸는 ESC·C0/C1 제어문자는
59
+ // 제거하되, 텍스트 스트리밍에 필요한 LF와 TAB은 그대로 둔다. CSI/OSC 등의 본문까지
60
+ // 먼저 제거해 ESC만 떼었을 때 남는 `[2J` 같은 노이즈도 남기지 않는다.
61
+ export function sanitizeTerminalText(value) {
62
+ return String(value ?? "")
63
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "") // OSC (title, hyperlink, clipboard…)
64
+ .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "") // DCS, SOS, PM, APC
65
+ .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "") // CSI + C1 CSI
66
+ .replace(/\x1b(?:[()][0-2A-Z0-9]|[=>][0-?]*|.)/g, "") // remaining ESC sequences
67
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
68
+ }
69
+
58
70
  // OSC 8 클릭 가능한 하이퍼링크. 무색(파이프·NO_COLOR·dumb)이면 텍스트만.
59
71
  export function hyperlink(url, text = url, level = style.level) {
60
72
  return level ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text;
package/src/config.mjs CHANGED
@@ -3,8 +3,9 @@ import { join } from "node:path";
3
3
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
4
  import { t } from "./i18n.mjs";
5
5
 
6
- // 공개 게이트웨이 루트. 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
7
- export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://router.pleum.ai")
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")
8
9
  .replace(/\/+$/, "")
9
10
  .replace(/\/v1$/, ""); // 사용자가 실수로 /v1까지 붙여도 V1이 /v1/v1 이중 경로가 되지 않게 정리(L12)
10
11
  // OpenAI 호환 엔드포인트는 /v1. (Claude Code만 루트를 쓴다 — recipes 참고)
package/src/launch.mjs CHANGED
@@ -1,19 +1,69 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdirSync, writeFileSync, existsSync } from "node:fs";
2
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join, delimiter } from "node:path";
4
+ import { join, delimiter, dirname, resolve } from "node:path";
5
5
  import { requireKey } from "./config.mjs";
6
6
  import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
7
7
  import { t } from "./i18n.mjs";
8
+ import { sanitizeTerminalText } from "./ansi.mjs";
8
9
 
9
10
  // PATH에서 실행 파일을 찾는다(설치 여부 표시용). menu.mjs도 사용.
10
11
  export function onPath(bin) {
11
- const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
12
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
12
13
  return (process.env.PATH || "")
13
14
  .split(delimiter)
14
15
  .some((d) => d && exts.some((e) => existsSync(join(d, bin + e))));
15
16
  }
16
17
 
18
+ // Windows는 .cmd/.bat 파일을 셸 없이 직접 실행할 수 없다. npm이 만드는 표준 .cmd
19
+ // shim은 실제 Node 엔트리포인트를 가리키므로 그 JS 파일을 현재 Node로 직접 실행한다.
20
+ // 임의의 batch script는 cmd.exe로 넘기지 않고 명시적으로 거부한다. 그래야 --model과
21
+ // `--` 패스스루 인자가 shell 문법으로 재해석될 수 없다.
22
+ function windowsCommandCandidates(bin, path, pathDelimiter) {
23
+ const hasPath = /[\\/]/.test(bin);
24
+ const hasExtension = /\.[^\\/]+$/.test(bin);
25
+ const names = hasExtension ? [bin] : [`${bin}.exe`, `${bin}.cmd`, `${bin}.bat`, bin];
26
+ const dirs = hasPath ? [""] : path.split(pathDelimiter).filter(Boolean);
27
+ return dirs.flatMap((dir) => names.map((name) => (dir ? join(dir, name) : name)));
28
+ }
29
+
30
+ function nodeEntryFromWindowsShim(script, { read = readFileSync, exists = existsSync } = {}) {
31
+ let source;
32
+ try {
33
+ source = String(read(script, "utf8"));
34
+ } catch {
35
+ return null;
36
+ }
37
+ // npm cmd-shim uses either %dp0% or %~dp0 followed by the package's JS entry.
38
+ const match = /"(?:%~dp0|%dp0%)[\\/]+([^"\r\n]+?\.(?:[cm]?js))"/i.exec(source);
39
+ if (!match) return null;
40
+ const entry = resolve(dirname(script), match[1].replace(/[\\/]+/g, "/"));
41
+ return exists(entry) ? entry : null;
42
+ }
43
+
44
+ // 플랫폼·filesystem 주입으로 Windows 경로 정책을 Linux/macOS에서도 회귀 테스트한다.
45
+ export function resolveLaunchCommand(bin, args, {
46
+ platform = process.platform,
47
+ path = process.env.PATH || "",
48
+ delimiter: pathDelimiter = delimiter,
49
+ exists = existsSync,
50
+ read = readFileSync,
51
+ node = process.execPath,
52
+ } = {}) {
53
+ if (platform !== "win32") return { command: bin, args };
54
+ const found = windowsCommandCandidates(bin, path, pathDelimiter).find((candidate) => exists(candidate));
55
+ if (!found) return { command: bin, args };
56
+ if (!/\.(?:cmd|bat)$/i.test(found)) return { command: found, args };
57
+
58
+ const entry = nodeEntryFromWindowsShim(found, { read, exists });
59
+ if (!entry) {
60
+ throw new Error(
61
+ `Windows command script '${bin}' cannot be launched safely. Install an .exe or a standard npm Node shim.`,
62
+ );
63
+ }
64
+ return { command: node, args: [entry, ...args] };
65
+ }
66
+
17
67
  // `pleum launch`(도구 미지정) — 지원 도구 + 설치 여부를 안내.
18
68
  export function launchHelp() {
19
69
  console.log(t("Usage: pleum launch <tool> [--model id] [-- tool args]\n"));
@@ -60,13 +110,22 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
60
110
 
61
111
  const args = [...r.args(model), ...passthrough];
62
112
  if (r.note) console.log(`ℹ ${t(r.note)}`);
63
- console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: `${r.bin} ${args.join(" ")}` })}\n`);
113
+ console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: sanitizeTerminalText(`${r.bin} ${args.join(" ")}`) })}\n`);
114
+
115
+ let command;
116
+ try {
117
+ command = resolveLaunchCommand(r.bin, args);
118
+ } catch (e) {
119
+ console.error(`✗ ${sanitizeTerminalText(e.message)}`);
120
+ if (exit) process.exit(1);
121
+ return 1;
122
+ }
64
123
 
65
124
  return await new Promise((resolve) => {
66
- const child = spawn(r.bin, args, {
125
+ const child = spawn(command.command, command.args, {
67
126
  env,
68
127
  stdio: "inherit",
69
- shell: process.platform === "win32",
128
+ shell: false,
70
129
  });
71
130
  child.on("error", (e) => {
72
131
  if (e.code === "ENOENT") {
@@ -74,7 +133,7 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
74
133
  if (exit) process.exit(127);
75
134
  return resolve(127);
76
135
  }
77
- console.error(`✗ ${e.message}`);
136
+ console.error(`✗ ${sanitizeTerminalText(e.message)}`);
78
137
  if (exit) process.exit(1);
79
138
  resolve(1);
80
139
  });
@@ -12,7 +12,7 @@ export const ES = {
12
12
  "Account": "Cuenta",
13
13
  "All models — price and context included": "Todos los modelos — precio y contexto incluidos",
14
14
  "Anthropic's coding agent — supports subagents": "Agente de codificación de Anthropic — admite subagentes",
15
- "Attaching and uploading {n} local image(s) from the prompt.": "Adjuntando y subiendo {n} imagen(es) local(es) del prompt.",
15
+ "Attaching and uploading {n} explicit local image(s).": "Adjuntando y subiendo {n} imagen(es) local(es) seleccionada(s) explícitamente.",
16
16
  "Auto-run:": "Ejecución automática:",
17
17
  "Autonomous dev agent": "Agente de desarrollo autónomo",
18
18
  "Multi-provider terminal coding agent": "Agente de codificación de terminal multi-proveedor",
@@ -32,7 +32,7 @@ export const ES = {
32
32
  "Choose a number, or search (Enter to cancel): ": "Elige un número, o busca (Enter para cancelar): ",
33
33
  "Choose a shell": "Elegir una shell",
34
34
  "Code": "Código",
35
- "Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: include an image file path right in the message (e.g. describe this ./shot.png)": "Comandos:\n /help, /? esta ayuda\n /clear reinicia el contexto de la conversación (conserva el system prompt)\n /system [text] define el system prompt (sin argumento muestra el valor actual)\n /model [id] cambia de modelo durante la sesión (sin argumento = selector con flechas)\n /models [query] explora la lista de modelos\n /save [name] guarda la sesión actual (por defecto: last)\n /load [name] carga una sesión guardada\n /verbose activa/desactiva estadísticas de tokens/velocidad\n /exit, /quit salir\n Entrada multilínea: una línea con \"\"\" → cuerpo → una línea con \"\"\"\n Imágenes: incluye la ruta de un archivo de imagen directamente en el mensaje (p. ej. describe esto ./shot.png)",
35
+ "Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "Comandos:\n /help, /? esta ayuda\n /clear reinicia el contexto de la conversación (conserva el system prompt)\n /system [text] define el system prompt (sin argumento muestra el valor actual)\n /model [id] cambia de modelo durante la sesión (sin argumento = selector con flechas)\n /models [query] explora la lista de modelos\n /save [name] guarda la sesión actual (por defecto: last)\n /load [name] carga una sesión guardada\n /verbose activa/desactiva estadísticas de tokens/velocidad\n /exit, /quit salir\n Entrada multilínea: una línea con \"\"\" → cuerpo → una línea con \"\"\"\n Imágenes: adjunta explícitamente con @./shot.png (o --attach ./shot.png para comandos de una sola ejecución)",
36
36
  "Context cleared": "Contexto borrado",
37
37
  "Ctrl+C again to quit (or /exit)": "Ctrl+C de nuevo para salir (o /exit)",
38
38
  "Current language: {name}": "Idioma actual: {name}",
@@ -12,7 +12,7 @@ export const JA = {
12
12
  "Account": "アカウント",
13
13
  "All models — price and context included": "全モデル — 価格・コンテキスト込み",
14
14
  "Anthropic's coding agent — supports subagents": "Anthropic のコーディングエージェント — サブエージェント対応",
15
- "Attaching and uploading {n} local image(s) from the prompt.": "プロンプトからローカル画像 {n} 件を添付・アップロード中。",
15
+ "Attaching and uploading {n} explicit local image(s).": "明示的に選択したローカル画像 {n} 件を添付・アップロード中。",
16
16
  "Auto-run:": "自動実行:",
17
17
  "Autonomous dev agent": "自律開発エージェント",
18
18
  "Multi-provider terminal coding agent": "マルチプロバイダー対応のターミナルコーディングエージェント",
@@ -32,7 +32,7 @@ export const JA = {
32
32
  "Choose a number, or search (Enter to cancel): ": "番号を選択、または検索してください (Enter でキャンセル): ",
33
33
  "Choose a shell": "シェルを選択",
34
34
  "Code": "コード",
35
- "Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: include an image file path right in the message (e.g. describe this ./shot.png)": "コマンド:\n /help, /? このヘルプ\n /clear 会話コンテキストをリセット (システムプロンプトは保持)\n /system [text] システムプロンプトを設定 (引数なしで現在値を表示)\n /model [id] セッション中にモデルを切り替え (引数なし = 矢印キーで選択)\n /models [query] モデル一覧を閲覧\n /save [name] 現在のセッションを保存 (デフォルト: last)\n /load [name] 保存したセッションを読み込み\n /verbose トークン/速度統計の表示切替\n /exit, /quit 終了\n 複数行入力: \"\"\" の行 → 本文 → \"\"\" の行\n 画像: メッセージ内に画像ファイルパスを直接含める (例: describe this ./shot.png)",
35
+ "Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "コマンド:\n /help, /? このヘルプ\n /clear 会話コンテキストをリセット (システムプロンプトは保持)\n /system [text] システムプロンプトを設定 (引数なしで現在値を表示)\n /model [id] セッション中にモデルを切り替え (引数なし = 矢印キーで選択)\n /models [query] モデル一覧を閲覧\n /save [name] 現在のセッションを保存 (デフォルト: last)\n /load [name] 保存したセッションを読み込み\n /verbose トークン/速度統計の表示切替\n /exit, /quit 終了\n 複数行入力: \"\"\" の行 → 本文 → \"\"\" の行\n 画像: @./shot.png で明示的に添付 (1回実行では --attach ./shot.png)",
36
36
  "Context cleared": "コンテキストをクリアしました",
37
37
  "Ctrl+C again to quit (or /exit)": "もう一度 Ctrl+C で終了 (または /exit)",
38
38
  "Current language: {name}": "現在の言語: {name}",
@@ -176,7 +176,7 @@ export const KO = {
176
176
  [`Specify a model: pleum run <model> ["prompt"] (model selection isn't available on non-interactive stdin)`]:
177
177
  `모델을 지정하세요: pleum run <model> ["프롬프트"] (비대화형 stdin에선 모델 선택 불가)`,
178
178
  'Usage: pleum run <model> ["prompt"]\nModel list: pleum models': '사용법: pleum run <model> ["프롬프트"]\n모델 목록: pleum models',
179
- "Attaching and uploading {n} local image(s) from the prompt.": "프롬프트의 로컬 이미지 {n}개를 첨부해 업로드합니다.",
179
+ "Attaching and uploading {n} explicit local image(s).": "명시적으로 선택한 로컬 이미지 {n}개를 첨부해 업로드합니다.",
180
180
  "Goodbye.": "안녕히 가세요.",
181
181
  "Ctrl+C again to quit (or /exit)": "한 번 더 Ctrl+C 하면 종료 (/exit 도 가능)",
182
182
  '/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation':
@@ -192,7 +192,7 @@ export const KO = {
192
192
  /verbose toggle token/speed stats
193
193
  /exit, /quit quit
194
194
  Multi-line input: a line with """ → body → a line with """
195
- Images: include an image file path right in the message (e.g. describe this ./shot.png)`]: `명령:
195
+ Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)`]: `명령:
196
196
  /help, /? 이 도움말
197
197
  /clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
198
198
  /system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
@@ -203,7 +203,7 @@ export const KO = {
203
203
  /verbose 토큰·속도 통계 표시 토글
204
204
  /exit, /quit 종료
205
205
  여러 줄 입력: """ 한 줄 → 본문 → """ 한 줄
206
- 이미지: 메시지에 이미지 파일 경로를 그대로 포함(예: 이 그림 설명해줘 ./shot.png)`,
206
+ 이미지: @./shot.png로 명시 첨부(1회 실행은 --attach ./shot.png)`,
207
207
  "Context cleared": "컨텍스트 초기화",
208
208
  "System prompt set": "시스템 프롬프트 설정",
209
209
  "Current system prompt:": "현재 시스템 프롬프트:",
@@ -12,7 +12,7 @@ export const ZH = {
12
12
  "Account": "账户",
13
13
  "All models — price and context included": "所有模型 — 含价格与上下文",
14
14
  "Anthropic's coding agent — supports subagents": "Anthropic 的编程代理 — 支持子代理",
15
- "Attaching and uploading {n} local image(s) from the prompt.": "正在附加并上传提示中的 {n} 张本地图片。",
15
+ "Attaching and uploading {n} explicit local image(s).": "正在附加并上传明确选择的 {n} 张本地图片。",
16
16
  "Auto-run:": "自动运行:",
17
17
  "Autonomous dev agent": "自主开发代理",
18
18
  "Multi-provider terminal coding agent": "多提供商终端编程代理",
@@ -32,7 +32,7 @@ export const ZH = {
32
32
  "Choose a number, or search (Enter to cancel): ": "选择编号,或搜索(Enter 取消): ",
33
33
  "Choose a shell": "选择 shell",
34
34
  "Code": "代码",
35
- "Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: include an image file path right in the message (e.g. describe this ./shot.png)": "命令:\n /help, /? 本帮助\n /clear 重置对话上下文(保留系统提示词)\n /system [text] 设置系统提示词(不带参数则显示当前值)\n /model [id] 会话中途切换模型(不带参数 = 方向键选择器)\n /models [query] 浏览模型列表\n /save [name] 保存当前会话(默认:last)\n /load [name] 加载已保存的会话\n /verbose 切换 token/速度统计\n /exit, /quit 退出\n 多行输入:一行 \"\"\" → 正文 → 一行 \"\"\"\n 图片:在消息中直接包含图片文件路径(例如 describe this ./shot.png)",
35
+ "Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "命令:\n /help, /? 本帮助\n /clear 重置对话上下文(保留系统提示词)\n /system [text] 设置系统提示词(不带参数则显示当前值)\n /model [id] 会话中途切换模型(不带参数 = 方向键选择器)\n /models [query] 浏览模型列表\n /save [name] 保存当前会话(默认:last)\n /load [name] 加载已保存的会话\n /verbose 切换 token/速度统计\n /exit, /quit 退出\n 多行输入:一行 \"\"\" → 正文 → 一行 \"\"\"\n 图片:使用 @./shot.png 显式附加(单次命令使用 --attach ./shot.png)",
36
36
  "Context cleared": "上下文已清除",
37
37
  "Ctrl+C again to quit (or /exit)": "再次按 Ctrl+C 退出(或 /exit)",
38
38
  "Current language: {name}": "当前语言:{name}",
package/src/login.mjs CHANGED
@@ -3,14 +3,24 @@ import { randomUUID } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
4
  import { ROOT, V1, readConfig, writeConfig, mergeLoginConfig } from "./config.mjs";
5
5
  import { t } from "./i18n.mjs";
6
+ import { sanitizeTerminalText } from "./ansi.mjs";
7
+
8
+ // `start`는 Windows cmd.exe의 built-in이라 shell:true가 필요하다. rundll32는 같은
9
+ // 기본 브라우저 핸드오프를 제공하는 실제 실행 파일이므로 URL을 argv로 안전하게 넘긴다.
10
+ export function browserCommand(url, platform = process.platform) {
11
+ if (platform === "darwin") return { command: "open", args: [url] };
12
+ if (platform === "win32") {
13
+ return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
14
+ }
15
+ return { command: "xdg-open", args: [url] };
16
+ }
6
17
 
7
18
  function openBrowser(url) {
8
- const cmd =
9
- process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
10
- spawn(cmd, [url], {
19
+ const { command, args } = browserCommand(url);
20
+ spawn(command, args, {
11
21
  stdio: "ignore",
12
22
  detached: true,
13
- shell: process.platform === "win32",
23
+ shell: false,
14
24
  }).unref();
15
25
  }
16
26
 
@@ -56,7 +66,7 @@ export async function login() {
56
66
  server.listen(0, "127.0.0.1", () => {
57
67
  const { port } = server.address();
58
68
  const authUrl = `${ROOT}/cli-auth?port=${port}&state=${state}`;
59
- console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${authUrl}\n`);
69
+ console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${sanitizeTerminalText(authUrl)}\n`);
60
70
  openBrowser(authUrl);
61
71
  });
62
72
 
@@ -81,8 +91,8 @@ export async function loginDevice() {
81
91
  const { device_code, user_code, verification_url, interval, expires_in } = await res.json();
82
92
 
83
93
  console.log(
84
- `\n ${t("Code")}: \x1b[1;32m${user_code}\x1b[0m\n` +
85
- ` URL: ${verification_url}\n\n` +
94
+ `\n ${t("Code")}: \x1b[1;32m${sanitizeTerminalText(user_code)}\x1b[0m\n` +
95
+ ` URL: ${sanitizeTerminalText(verification_url)}\n\n` +
86
96
  ` ${t("Log in at the URL above, then enter the code (within {min} min).", { min: expires_in / 60 })}\n`
87
97
  );
88
98
 
@@ -98,7 +108,7 @@ export async function loginDevice() {
98
108
  if (!poll.ok) {
99
109
  const e = await poll.json().catch(() => ({}));
100
110
  if (e.detail === "expired_token") { console.error(t("The code has expired.")); process.exit(1); }
101
- console.error(t("Error:"), e.detail ?? poll.status);
111
+ console.error(t("Error:"), sanitizeTerminalText(e.detail ?? poll.status));
102
112
  process.exit(1);
103
113
  }
104
114
  const { key } = await poll.json();
package/src/markdown.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // 전략: \n이 도착해 완결된 줄은 스타일 입혀 확정 출력(재렌더 없음), 아직 안 끝난
3
3
  // 마지막 줄만 매 push마다 지우고 다시 그린다. 비TTY(파이프)는 완전 패스스루.
4
4
 
5
- import { style, displayWidth, terminalWidth } from "./ansi.mjs";
5
+ import { style, displayWidth, terminalWidth, sanitizeTerminalText } from "./ansi.mjs";
6
6
 
7
7
  // ```lang 펜스 감지(순수 함수). 열기/닫기 구분은 호출자의 inFence 상태가 한다.
8
8
  export function detectFence(line) {
@@ -13,7 +13,7 @@ export function detectFence(line) {
13
13
  // 인라인 마크다운: `code` → cyan, **bold**, *italic*. 코드 조각 먼저 치환해
14
14
  // 코드 안의 별표가 bold로 오인되는 흔한 케이스를 줄인다.
15
15
  export function renderInline(text, styler = style) {
16
- return text
16
+ return sanitizeTerminalText(text)
17
17
  .replace(/`([^`]+)`/g, (_, c) => styler.cyan(c))
18
18
  .replace(/\*\*([^*]+)\*\*/g, (_, c) => styler.bold(c))
19
19
  .replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, (_, pre, c) => pre + styler.italic(c));
@@ -21,6 +21,7 @@ export function renderInline(text, styler = style) {
21
21
 
22
22
  // 완결된 한 줄 렌더(순수 함수). state.inFence는 이 줄 "이전"의 펜스 상태.
23
23
  export function renderLine(line, state = { inFence: false }, styler = style) {
24
+ line = sanitizeTerminalText(line);
24
25
  const fence = detectFence(line);
25
26
  if (fence) {
26
27
  return state.inFence ? styler.gray("└─") : styler.gray("┌─ ") + styler.dim(fence.lang || "code");
@@ -67,6 +68,8 @@ export function createMarkdownStream({
67
68
 
68
69
  return {
69
70
  push(delta) {
71
+ delta = sanitizeTerminalText(delta);
72
+ if (!delta) return;
70
73
  if (!interactive) return void write(delta);
71
74
  buf += delta;
72
75
  const lines = buf.split("\n");
package/src/menu.mjs CHANGED
@@ -11,12 +11,12 @@
11
11
  import { readFileSync } from "node:fs";
12
12
  import { spawn } from "node:child_process";
13
13
  import { createInterface } from "node:readline";
14
- import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
14
+ import { style, displayWidth, terminalWidth, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
15
15
  import { keyFeeder, select } from "./interactive.mjs";
16
16
  import { readConfig, writeConfig } from "./config.mjs";
17
17
  import { RECIPES, MANUAL } from "./recipes.mjs";
18
18
  import { onPath, launch } from "./launch.mjs";
19
- import { fetchModels, matchModels, listModels } from "./models.mjs";
19
+ import { fetchModels, matchModels, listModels, formatModelLabel } from "./models.mjs";
20
20
  import { run } from "./run.mjs";
21
21
  import { login } from "./login.mjs";
22
22
  import { top } from "./top.mjs";
@@ -115,12 +115,12 @@ async function pickModelFor(item) {
115
115
  try {
116
116
  models = await fetchModels();
117
117
  } catch (e) {
118
- console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
118
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
119
119
  return undefined;
120
120
  }
121
121
  const m = await select(models, {
122
122
  message: `${item.title} — ${t("select a model")}`,
123
- formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
123
+ formatItem: (x) => formatModelLabel(x),
124
124
  matches: matchModels,
125
125
  limit: 12,
126
126
  });
package/src/models.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { V1 } from "./config.mjs";
2
- import { style, padDisplay, divider } from "./ansi.mjs";
2
+ import { style, padDisplay, divider, sanitizeTerminalText } from "./ansi.mjs";
3
3
  import { t } from "./i18n.mjs";
4
4
 
5
5
  // GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
@@ -15,16 +15,28 @@ export function matchModels(models, query) {
15
15
  const q = (query || "").toLowerCase();
16
16
  if (!q) return models;
17
17
  return models.filter(
18
- (m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
18
+ (m) => String(m.id ?? "").toLowerCase().includes(q) || String(m.display_name ?? "").toLowerCase().includes(q),
19
19
  );
20
20
  }
21
21
 
22
+ // Model registry data is remote input. Keep this boundary before any style/render operation so
23
+ // formatting codes emitted by the CLI itself are the only ANSI sequences written to a terminal.
24
+ export function safeModelText(value, fallback = "") {
25
+ return sanitizeTerminalText(value ?? fallback);
26
+ }
27
+
28
+ export function formatModelLabel(model, styler = style) {
29
+ const id = safeModelText(model?.id);
30
+ const displayName = safeModelText(model?.display_name);
31
+ return `${id}${displayName ? ` ${styler.dim(displayName)}` : ""}`;
32
+ }
33
+
22
34
  // 컨텍스트 길이를 사람이 읽기 좋게: 128000→128K, 1000000→1M.
23
35
  export function fmtCtx(n) {
24
36
  if (!n) return "—";
25
37
  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
26
38
  if (n >= 1000) return `${Math.round(n / 1000)}K`;
27
- return String(n);
39
+ return safeModelText(n);
28
40
  }
29
41
 
30
42
  // pricing(_krw)는 토큰당 단가 문자열. 1M 토큰당으로 환산해 표시(KRW 우선, 없으면 USD).
@@ -49,9 +61,9 @@ export function formatTable(models, styler = style) {
49
61
  const rows = models.map((m) => {
50
62
  const [pin, pout] = fmtPrice(m);
51
63
  return [
52
- m.id,
53
- m.provider || "",
54
- m.modality || "text",
64
+ safeModelText(m.id),
65
+ safeModelText(m.provider || ""),
66
+ safeModelText(m.modality || "text"),
55
67
  fmtCtx(m.context_length || m.context_window),
56
68
  pin,
57
69
  pout,
@@ -119,14 +131,16 @@ const fmtNum = (n) => (n == null ? "—" : Number(n).toLocaleString("en-US"));
119
131
  export function renderModelInfo(m, styler = style) {
120
132
  const [pin, pout] = fmtPrice(m);
121
133
  const lines = [];
122
- lines.push(`${styler.bold(styler.plum(m.id))}${m.display_name ? ` ${styler.dim(m.display_name)}` : ""}`);
134
+ const id = safeModelText(m.id);
135
+ const displayName = safeModelText(m.display_name);
136
+ lines.push(`${styler.bold(styler.plum(id))}${displayName ? ` ${styler.dim(displayName)}` : ""}`);
123
137
  lines.push(divider(undefined, styler));
124
138
  const facts = [
125
- ["maker", m.maker || "—"],
126
- ["modality", m.modality || "text"],
139
+ ["maker", safeModelText(m.maker || "—")],
140
+ ["modality", safeModelText(m.modality || "text")],
127
141
  ["context", fmtCtx(m.context_length || m.context_window)],
128
142
  ["max output", fmtCtx(m.max_output_tokens || m.max_output)],
129
- [t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: m.effective_markup_percent ?? 0 }))}`],
143
+ [t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: safeModelText(m.effective_markup_percent, 0) }))}`],
130
144
  ];
131
145
  if (m.supports_thinking) {
132
146
  facts.push([
@@ -160,19 +174,19 @@ export function renderModelInfo(m, styler = style) {
160
174
  const [i1m, o1m] = fmtPrice(p);
161
175
  lines.push(
162
176
  [
163
- styler.plum(padDisplay(p.provider, 12)),
177
+ styler.plum(padDisplay(safeModelText(p.provider), 12)),
164
178
  padDisplay(i1m, 10),
165
179
  padDisplay(o1m, 10),
166
180
  padDisplay(fmtCtx(p.context_window), 5),
167
181
  padDisplay(p.latency_ms != null ? `${Math.round(p.latency_ms)}ms` : "—", 8),
168
182
  padDisplay(p.throughput_tps != null ? `${Math.round(p.throughput_tps)}/s` : "—", 6),
169
- p.uptime_percent != null ? `${p.uptime_percent}%` : "—",
183
+ p.uptime_percent != null ? `${safeModelText(p.uptime_percent)}%` : "—",
170
184
  ].join(" "),
171
185
  );
172
186
  }
173
187
  }
174
188
 
175
- const sibs = (m.siblings || []).map((s) => s.id);
189
+ const sibs = (m.siblings || []).map((s) => safeModelText(s.id));
176
190
  if (sibs.length) {
177
191
  lines.push("");
178
192
  lines.push(`${styler.dim(t("Same provider:"))} ${sibs.join(styler.dim(" · "))}`);
package/src/recipes.mjs CHANGED
@@ -169,15 +169,33 @@ export const RECIPES = {
169
169
 
170
170
  // Codex는 Chat Completions가 제거돼 Responses API(wire_api="responses")만 지원.
171
171
  // 사용자의 ~/.codex/config.toml을 건드리지 않도록 격리 CODEX_HOME에 이 파일을 쓴다.
172
+ // TOML basic string encoder. 모델명과 base_url은 CLI/env에서 오므로, 따옴표·개행이
173
+ // config의 새 키/테이블로 해석되지 않도록 모든 제어문자를 이스케이프한다.
174
+ export function tomlBasicString(value) {
175
+ const escapes = {
176
+ "\b": "\\b",
177
+ "\t": "\\t",
178
+ "\n": "\\n",
179
+ "\f": "\\f",
180
+ "\r": "\\r",
181
+ '"': '\\"',
182
+ "\\": "\\\\",
183
+ };
184
+ const escaped = String(value).replace(/[\u0000-\u001f\u007f"\\\\]/g, (ch) =>
185
+ escapes[ch] ?? `\\u${ch.codePointAt(0).toString(16).padStart(4, "0")}`,
186
+ );
187
+ return `"${escaped}"`;
188
+ }
189
+
172
190
  export function codexToml(model) {
173
191
  const m = model || "gpt-5.4-mini";
174
192
  return `# Generated by pleum CLI. Isolated CODEX_HOME — your ~/.codex is untouched.
175
- model = "${m}"
193
+ model = ${tomlBasicString(m)}
176
194
  model_provider = "pleum"
177
195
 
178
196
  [model_providers.pleum]
179
197
  name = "PleumRouter"
180
- base_url = "${V1}"
198
+ base_url = ${tomlBasicString(V1)}
181
199
  env_key = "PLEUM_API_KEY"
182
200
  wire_api = "responses"
183
201
  `;
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
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";
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,10 +195,11 @@ 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);
202
+ const oneShot = Boolean(inlinePrompt || piped || attachments.length);
121
203
  const key = requireKey();
122
204
  const callOpts = { tags, skipCache, reasoningEffort, serviceTier, reasoningMode };
123
205
 
@@ -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";
@@ -82,12 +82,12 @@ async function pickDefaultModelFor(item) {
82
82
  try {
83
83
  models = await fetchModels();
84
84
  } catch (e) {
85
- console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
85
+ console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
86
86
  return undefined;
87
87
  }
88
88
  const picked = await select([CLEAR_OPTION, ...models], {
89
89
  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)}` : ""}`),
90
+ formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) : formatModelLabel(x)),
91
91
  matches: matchModels, // 쿼리 입력 시 CLEAR_OPTION은 자연히 필터링됨(id/display_name 불일치) — 별도 처리 불요
92
92
  limit: 12,
93
93
  });