pleumrouter 0.5.45 → 0.5.46

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.45",
3
+ "version": "0.5.46",
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,7 +1,8 @@
1
- import { authFetch, getActiveContext, setActiveContext } from "./config.mjs";
1
+ import { authFetch, getActiveContext } from "./config.mjs";
2
2
  import { style } from "./ansi.mjs";
3
3
  import { select } from "./interactive.mjs";
4
4
  import { t } from "./i18n.mjs";
5
+ import { fetchWorkspaceContext, selectAndBindWorkspace, workspaceLabel } from "./workspace.mjs";
5
6
 
6
7
  export function formatKrwCredit(amount) {
7
8
  return Number(amount || 0).toLocaleString("ko-KR", {
@@ -10,146 +11,59 @@ export function formatKrwCredit(amount) {
10
11
  });
11
12
  }
12
13
 
13
- export async function fetchCreditSummary() {
14
- const res = await authFetch("/credits");
14
+ // 개인 지갑 잔액만 조회한다. 공유 공간 목록·선택은 selectAndBindWorkspace가 맡는다.
15
+ export async function fetchCreditSummary({ auth = authFetch } = {}) {
16
+ const res = await auth("/credits");
15
17
  if (!res.ok) {
16
18
  const body = await res.text().catch(() => "");
17
19
  throw new Error(t("Failed to fetch balance ({status})", { status: res.status }) + (body ? `: ${body}` : ""));
18
20
  }
19
21
  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
22
  return {
52
23
  personal: {
53
24
  balance_krw: personal.balance_krw ?? 0,
54
25
  balance_usd: personal.balance_usd,
55
26
  },
56
- teams: teamWallets,
57
27
  };
58
28
  }
59
29
 
60
- export function buildCreditOptions({ personal, teams = [], activeContext = { type: "personal" } }) {
61
- const options = [];
30
+ export function buildCreditOptions({ personal, activeContext = { type: "personal" } }) {
62
31
  const isPersonalActive = activeContext.type === "personal";
63
32
  const personalBalanceStr = `${formatKrwCredit(personal.balance_krw)} ${t("credits")}${
64
33
  personal.balance_usd != null ? ` / $${personal.balance_usd.toFixed(4)}` : ""
65
34
  }`;
66
35
 
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;
36
+ return [
37
+ {
38
+ id: "personal",
39
+ type: "personal",
40
+ name: t("Personal"),
41
+ title: t("Personal Credit"),
42
+ balance: personalBalanceStr,
43
+ isActive: isPersonalActive,
44
+ label: `${isPersonalActive ? "✓ " : " "}${t("Personal Credit")}: ${personalBalanceStr}`,
45
+ },
46
+ ];
94
47
  }
95
48
 
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;
49
+ // 과금 공간 전환. 후보 선택·키 재바인딩·저장은 selectAndBindWorkspace가 한다.
50
+ export async function switchCreditWorkspace({ selectFn = select } = {}) {
51
+ return selectAndBindWorkspace({ selectFn });
52
+ }
125
53
 
54
+ // 전환 안내는 공유 공간 후보가 있을 때만 띄운다. 조회가 실패하면 조용히 생략한다.
55
+ async function hasWorkspaceChoices() {
126
56
  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;
57
+ const data = await fetchWorkspaceContext();
58
+ return (data?.workspace_candidates ?? []).length > 0;
59
+ } catch {
60
+ return false;
140
61
  }
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
62
  }
151
63
 
152
64
  export async function balance({ interactive = false, switchOnly = false, exitOnError = true } = {}) {
65
+ if (switchOnly) return await switchCreditWorkspace();
66
+
153
67
  let summary;
154
68
  try {
155
69
  summary = await fetchCreditSummary();
@@ -159,28 +73,15 @@ export async function balance({ interactive = false, switchOnly = false, exitOnE
159
73
  return;
160
74
  }
161
75
 
162
- if (switchOnly) {
163
- return await switchCreditWorkspace({ summary });
164
- }
165
-
166
76
  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;
77
+ const [personal] = buildCreditOptions({ personal: summary.personal, activeContext: active });
78
+ const mark = personal.isActive ? style.bold(style.plum("▸ [Active] ")) : " [ ] ";
79
+ console.log(t("Credit Balance:"));
80
+ console.log(`${mark}${personal.title}: ${personal.balance}`);
81
+ console.log(` ${t("Credit workspace")}: ${workspaceLabel(active)}`);
82
+
83
+ if (interactive) return await switchCreditWorkspace();
84
+ if (await hasWorkspaceChoices()) {
85
+ console.log(`\n${style.dim(t("Run `pleum balance --switch` to switch credit workspace."))}`);
183
86
  }
184
-
185
- return await switchCreditWorkspace({ summary });
186
87
  }
package/src/config.mjs CHANGED
@@ -215,9 +215,7 @@ export function getActiveContext(cfg = readConfig()) {
215
215
  workspace_name: ctx.workspace_name || ctx.workspace_id,
216
216
  };
217
217
  }
218
- if (ctx && ctx.type === "team" && ctx.org_id) {
219
- return { type: "team", org_id: ctx.org_id, org_name: ctx.org_name || ctx.org_id };
220
- }
218
+ // 팀 컨텍스트는 제거됐다. 예전에 저장된 team 값도 개인으로 본다.
221
219
  return { type: "personal", org_id: null, org_name: null };
222
220
  }
223
221
 
@@ -230,29 +228,18 @@ export function setActiveContext(context, cfg = readConfig()) {
230
228
  workspace_id: context.workspace_id,
231
229
  workspace_name: context.workspace_name || null,
232
230
  };
233
- } else if (!context || context.type === "personal" || !context.org_id) {
234
- updated.active_context = { type: "personal" };
235
231
  } else {
236
- updated.active_context = {
237
- type: "team",
238
- org_id: context.org_id,
239
- org_name: context.org_name || null,
240
- };
232
+ updated.active_context = { type: "personal" };
241
233
  }
242
234
  writeConfig(updated);
243
235
  return updated.active_context;
244
236
  }
245
237
 
246
- /** Bearer 요청 — 401이면 1회 refresh 후 재시도. 활성 팀 컨텍스트가 있으면 X-Pleum-Org 첨부. */
238
+ /** Bearer 요청 — 401이면 1회 refresh 후 재시도. 과금 귀속은 키의 workspace_id가 정하므로 헤더를 붙이지 않는다. */
247
239
  export async function authFetch(path, init = {}) {
248
240
  const token = await requireAuth();
249
241
  const url = path.startsWith("http") ? path : `${V1}${path.startsWith("/") ? path : `/${path}`}`;
250
- const ctx = getActiveContext();
251
- const customHeaders = {};
252
- if (ctx.type === "team" && ctx.org_id) {
253
- customHeaders["X-Pleum-Org"] = ctx.org_id;
254
- }
255
- const headers = { ...customHeaders, ...(init.headers || {}), Authorization: `Bearer ${token}` };
242
+ const headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` };
256
243
  let res = await fetch(url, { ...init, headers });
257
244
  if (res.status !== 401) return res;
258
245
 
@@ -261,7 +248,7 @@ export async function authFetch(path, init = {}) {
261
248
  if (!refreshed) return res;
262
249
  return fetch(url, {
263
250
  ...init,
264
- headers: { ...customHeaders, ...(init.headers || {}), Authorization: `Bearer ${refreshed}` },
251
+ headers: { ...(init.headers || {}), Authorization: `Bearer ${refreshed}` },
265
252
  });
266
253
  }
267
254
 
package/src/launch.mjs CHANGED
@@ -5,6 +5,7 @@ import { join, delimiter, dirname, resolve } from "node:path";
5
5
  import { readConfig, isLoggedIn, getActiveContext, ROOT } from "./config.mjs";
6
6
  import { login } from "./login.mjs";
7
7
  import { ensureWireKey } from "./wire.mjs";
8
+ import { workspaceLabel } from "./workspace.mjs";
8
9
  import {
9
10
  RECIPES,
10
11
  CORE_MODELS,
@@ -316,12 +317,7 @@ export async function launch(tool, model, passthrough, {
316
317
  process.removeListener("unhandledRejection", onException);
317
318
  };
318
319
 
319
- const activeCtx = getActiveContext();
320
- const creditMsg = activeCtx.type === "workspace"
321
- ? `${t("Workspace")}: ${sanitizeTerminalText(activeCtx.workspace_name)}`
322
- : activeCtx.type === "team"
323
- ? `${t("Team")}: ${activeCtx.org_name}`
324
- : t("Personal");
320
+ const creditMsg = workspaceLabel(getActiveContext());
325
321
  console.log(`ℹ ${t("Credit:")} ${creditMsg}`);
326
322
 
327
323
  const args = [...r.args(model), ...passthrough];
@@ -169,6 +169,8 @@ export const ES = {
169
169
  "Unsupported tool: {tool}. Use claude or codex.": "Herramienta no compatible: {tool}. Usa claude o codex.",
170
170
  "Wrote {path}": "Escrito en {path}",
171
171
  "Wired {tool}.": "Cableado {tool}.",
172
+ "The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them.": "La clave de conexión la comparten todas las herramientas conectadas y launch, así que cambiar el espacio de facturación se aplica a todas.",
173
+ "{path}: not valid JSON — fix or remove it, nothing was written": "{path}: no es JSON válido — corríjalo o elimínelo; no se escribió nada",
172
174
  "Desktop key: {prefix}…": "Clave de escritorio: {prefix}…",
173
175
  "Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (no /v1), Bearer, paste the plm_ key.":
174
176
  "Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (sin /v1), Bearer, pega la clave plm_.",
@@ -244,6 +246,7 @@ export const ES = {
244
246
  "↑/↓ move • enter set model • esc back": "↑/↓ mover • enter definir modelo • esc volver",
245
247
  "⏹ aborted": "⏹ abortado",
246
248
  "⏹ generation aborted — the partial reply stays in context": "⏹ generación abortada — la respuesta parcial permanece en el contexto",
249
+ "partial reply kept in context": "la respuesta parcial se mantuvo en el contexto",
247
250
  "✓ installed": "✓ instalado",
248
251
  "✗ not installed —": "✗ no instalado —",
249
252
  "Log out": "Cerrar sesión",
@@ -169,6 +169,8 @@ export const JA = {
169
169
  "Example: pleum wire": "例: pleum wire",
170
170
  "Wrote {path}": "{path} に書きました",
171
171
  "Wired {tool}.": "{tool} を配線しました。",
172
+ "The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them.": "配線キーは配線済みのすべてのツールと launch で共有されるため、課金ワークスペースを変更するとすべてに適用されます。",
173
+ "{path}: not valid JSON — fix or remove it, nothing was written": "{path}: 有効な JSON ではありません — 修正するか削除してください。何も書き込んでいません",
172
174
  "Desktop key: {prefix}…": "デスクトップキー: {prefix}…",
173
175
  "Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (no /v1), Bearer, paste the plm_ key.":
174
176
  "Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root}(/v1 なし)、Bearer、plm_ キーを貼り付けます。",
@@ -244,6 +246,7 @@ export const JA = {
244
246
  "↑/↓ move • enter set model • esc back": "↑/↓ 移動 • enter モデル設定 • esc 戻る",
245
247
  "⏹ aborted": "⏹ 中断しました",
246
248
  "⏹ generation aborted — the partial reply stays in context": "⏹ 生成を中断しました — 途中までの応答はコンテキストに残ります",
249
+ "partial reply kept in context": "途中まで受信した応答をコンテキストに残しました",
247
250
  "✓ installed": "✓ インストール済み",
248
251
  "✗ not installed —": "✗ 未インストール —",
249
252
  "Log out": "ログアウト",
@@ -233,6 +233,7 @@ export const KO = {
233
233
  "{model} thinking…": "{model} 생각 중…",
234
234
  "⏹ aborted": "⏹ 중단됨",
235
235
  "⏹ generation aborted — the partial reply stays in context": "⏹ 생성 중단됨 — 부분 응답은 컨텍스트에 유지",
236
+ "partial reply kept in context": "중간까지 받은 응답을 컨텍스트에 남겼습니다",
236
237
  "Select a model": "모델 선택",
237
238
  "… {n} more — narrow with a search term": "… {n}개 더 — 검색어로 좁히기",
238
239
  "Choose a number, or search (Enter to cancel): ": "번호 선택 또는 검색어 (Enter=취소): ",
@@ -275,6 +276,8 @@ export const KO = {
275
276
  "Unsupported tool: {tool}. Use claude or codex.": "지원하지 않는 도구: {tool}. claude 또는 codex를 쓰세요.",
276
277
  "Wrote {path}": "{path}에 썼습니다",
277
278
  "Wired {tool}.": "{tool}을 배선했습니다.",
279
+ "The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them.": "배선 키는 배선된 모든 도구와 launch가 공유하므로, 과금 공간을 바꾸면 전부에 적용됩니다.",
280
+ "{path}: not valid JSON — fix or remove it, nothing was written": "{path}: JSON 형식이 아닙니다 — 고치거나 삭제하세요. 아무것도 쓰지 않았습니다",
278
281
  "Desktop key: {prefix}…": "데스크톱 키: {prefix}…",
279
282
  "Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (no /v1), Bearer, paste the plm_ key.":
280
283
  "Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (/v1 없음), Bearer, plm_ 키를 붙여넣으세요.",
@@ -169,6 +169,8 @@ export const ZH = {
169
169
  "Unsupported tool: {tool}. Use claude or codex.": "不支持的工具:{tool}。请使用 claude 或 codex。",
170
170
  "Wrote {path}": "已写入 {path}",
171
171
  "Wired {tool}.": "已接线 {tool}。",
172
+ "The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them.": "接入密钥由所有已接入的工具和 launch 共享,因此更改计费工作区会对全部生效。",
173
+ "{path}: not valid JSON — fix or remove it, nothing was written": "{path}: 不是有效的 JSON — 请修复或删除,未写入任何内容",
172
174
  "Desktop key: {prefix}…": "桌面密钥:{prefix}…",
173
175
  "Claude Code Desktop: Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference. Gateway base URL {root} (no /v1), Bearer, paste the plm_ key.":
174
176
  "Claude Code Desktop:Help → Troubleshooting → Enable Developer Mode → Developer → Configure Third-Party Inference。Gateway base URL {root}(不要 /v1),Bearer,粘贴 plm_ 密钥。",
@@ -244,6 +246,7 @@ export const ZH = {
244
246
  "↑/↓ move • enter set model • esc back": "↑/↓ 移动 • enter 设置模型 • esc 返回",
245
247
  "⏹ aborted": "⏹ 已中止",
246
248
  "⏹ generation aborted — the partial reply stays in context": "⏹ 生成已中止 — 部分回复保留在上下文中",
249
+ "partial reply kept in context": "已接收的部分回复已保留在上下文中",
247
250
  "✓ installed": "✓ 已安装",
248
251
  "✗ not installed —": "✗ 未安装 —",
249
252
  "Log out": "登出",
package/src/menu.mjs CHANGED
@@ -16,7 +16,7 @@ import { runSettings } from "./settings.mjs";
16
16
  import { t } from "./i18n.mjs";
17
17
  import { parseInstallCommand, runInstallCommand, printManualInstall } from "./install.mjs";
18
18
  import { getWireStatus, wire, unwire, WIRE_WARNINGS } from "./wire.mjs";
19
- import { selectAndBindWorkspace } from "./workspace.mjs";
19
+ import { selectAndBindWorkspace, workspaceLabel } from "./workspace.mjs";
20
20
 
21
21
  // 값은 t() 키(영어 원문)만 담는다 — 여기서 t()를 호출하면 모듈 로드 시점(import 1회)에
22
22
  // 번역이 확정돼, 같은 프로세스 안에서 언어를 바꿔도(Settings) 이 설명들만 예전 언어로 남는다.
@@ -74,9 +74,7 @@ export function buildItems({ savedModels = {}, activeContext = { type: "personal
74
74
  title: t("Chat with a model"),
75
75
  desc: `${t("Start an interactive chat")}${savedModels.chat ? ` · ${t("model")}: ${savedModels.chat}` : ""}`,
76
76
  });
77
- const contextDesc = activeContext?.type === "team" && activeContext.org_name
78
- ? `${t("Team")}: ${activeContext.org_name}`
79
- : t("Personal");
77
+ const contextDesc = workspaceLabel(activeContext);
80
78
  items.push(
81
79
  { id: "models", title: t("Model list"), desc: t("All models — price and context included") },
82
80
  { id: "balance", title: t("Balance"), desc: `${t("Check your credit balance")} · ${contextDesc}` },
@@ -624,7 +622,7 @@ export async function runWireMenu({ loop = true } = {}) {
624
622
  if (selectedModels.length > 1) {
625
623
  const defaultChoice = await select(selectedModels.map((id) => ({ id })), {
626
624
  message: `${r.label} — ${t("Select default startup model")}`,
627
- formatItem: (x) => x.id,
625
+ formatItem: (x) => sanitizeTerminalText(x.id),
628
626
  });
629
627
  if (defaultChoice) selectedModel = defaultChoice.id;
630
628
  } else if (selectedModels.length === 1) {
package/src/run.mjs CHANGED
@@ -254,6 +254,7 @@ async function repl(initialModel, initialVerbose, {
254
254
  let lastSigint = 0;
255
255
  let pickerActive = false; // /model 셀렉터가 rl을 잠시 닫는 동안 close 핸들러 무시
256
256
  let rl;
257
+ let queue = Promise.resolve(); // 입력 직렬화 큐 — /model 피커가 rl을 다시 만들어도 유지된다
257
258
 
258
259
  const send = async (text) => {
259
260
  let added = false;
@@ -277,7 +278,11 @@ async function repl(initialModel, initialVerbose, {
277
278
  else messages.pop(); // 응답 없음(즉시 중단 등) — user 턴 되돌림
278
279
  } catch (e) {
279
280
  console.error(`\n${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
280
- if (added) messages.pop(); // 실패한 user 턴 제거
281
+ if (e.partialText) {
282
+ // 중간에 끊긴 답도 맥락에 남겨 다음 질문이 이어받게 한다.
283
+ messages.push({ role: "assistant", content: e.partialText });
284
+ console.error(style.dim(t("partial reply kept in context")));
285
+ } else if (added) messages.pop(); // 실패한 user 턴 제거
281
286
  } finally {
282
287
  currentAbort = null;
283
288
  }
@@ -388,7 +393,10 @@ async function repl(initialModel, initialVerbose, {
388
393
  history,
389
394
  historySize: 1000,
390
395
  });
391
- rl.on("line", onLine);
396
+ // 생성 중 입력은 순서대로 대기한다. 여러 줄을 한 질문으로 보내려면 """ 로 감싼다.
397
+ rl.on("line", (l) => {
398
+ queue = queue.then(() => onLine(l)).catch((e) => console.error(`✗ ${sanitizeTerminalText(e.message)}`));
399
+ });
392
400
  rl.on("close", onClose);
393
401
  rl.on("SIGINT", onSigint);
394
402
  return rl;
@@ -542,6 +550,7 @@ export async function streamChat(model, messages, {
542
550
  let cost = null;
543
551
  let firstAt = 0;
544
552
  let aborted = false;
553
+ let streamError = null; // 서버가 스트림 중간에 보낸 에러 프레임
545
554
  const decoder = new TextDecoder();
546
555
  try {
547
556
  for await (const chunk of res.body) {
@@ -555,6 +564,8 @@ export async function streamChat(model, messages, {
555
564
  if (data === "[DONE]") continue;
556
565
  try {
557
566
  const j = JSON.parse(data);
567
+ // 여기서 throw하면 아래 catch가 삼키므로 기록만 해 두고 루프 뒤에 던진다.
568
+ if (j.error) streamError = { message: j.error.message || "stream error", partial: j.error.partial === true };
558
569
  if (j.usage) usage = j.usage;
559
570
  if (j.cost) cost = j.cost; // 마지막 청크 — {usd, krw, fx_rate, markup_rate}
560
571
  const delta = j.choices?.[0]?.delta?.content;
@@ -583,6 +594,12 @@ export async function streamChat(model, messages, {
583
594
  sp.stop();
584
595
  md.finish();
585
596
  if (aborted) console.error(style.dim(t("⏹ generation aborted — the partial reply stays in context")));
597
+ if (streamError) {
598
+ // 받은 부분 응답을 함께 실어, REPL이 끊긴 답도 맥락에 남길 수 있게 한다.
599
+ const err = new Error(sanitizeTerminalText(streamError.message));
600
+ err.partialText = full;
601
+ throw err;
602
+ }
586
603
 
587
604
  const ms = Date.now() - t0;
588
605
  const genSec = (Date.now() - (firstAt || t0)) / 1000;
package/src/settings.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // 즉시 참조하면 순환의 평가 순서에 따라 깨질 수 있다).
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
- import { style, sanitizeTerminalText } from "./ansi.mjs";
9
+ import { style } from "./ansi.mjs";
10
10
  import { select, confirm } from "./interactive.mjs";
11
11
  import { readConfig, writeConfig, isLoggedIn, getActiveContext } from "./config.mjs";
12
12
  import { getLocale, LANGS, t } from "./i18n.mjs";
@@ -16,6 +16,7 @@ import { login, logout } from "./login.mjs";
16
16
  import { completion } from "./completion.mjs";
17
17
  import { lang } from "./lang.mjs";
18
18
  import { switchCreditWorkspace } from "./balance.mjs";
19
+ import { workspaceLabel } from "./workspace.mjs";
19
20
  import { runList, saveModel } from "./menu.mjs";
20
21
  import { checkLatestVersion, isNewerVersion, runUpdateCommand } from "./update.mjs";
21
22
 
@@ -28,11 +29,7 @@ export function buildSettingsItems({
28
29
  activeContext = { type: "personal" },
29
30
  latestVersion = null,
30
31
  }) {
31
- const contextDesc = activeContext?.type === "workspace"
32
- ? `${t("Workspace")}: ${sanitizeTerminalText(activeContext.workspace_name || activeContext.workspace_id)}`
33
- : activeContext?.type === "team"
34
- ? `${t("Team")}: ${activeContext.org_name || activeContext.org_id}`
35
- : t("Personal Credit");
32
+ const contextDesc = activeContext?.type === "workspace" ? workspaceLabel(activeContext) : t("Personal Credit");
36
33
  const updateDesc = latestVersion && isNewerVersion(version, latestVersion)
37
34
  ? `${t("Current version: {version}", { version })} → v${latestVersion} (${t("Update available")})`
38
35
  : t("Current version: {version}", { version });
package/src/wire.mjs CHANGED
@@ -1,9 +1,22 @@
1
1
  import { homedir } from "node:os";
2
- import { dirname, join } from "node:path";
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ readlinkSync,
10
+ realpathSync,
11
+ renameSync,
12
+ statSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
4
16
  import { ROOT, V1, authFetch, getActiveContext } from "./config.mjs";
5
17
  import { tomlBasicString, RECIPES } from "./recipes.mjs";
6
18
  import { t } from "./i18n.mjs";
19
+ import { selectAndBindWorkspace, workspaceLabel } from "./workspace.mjs";
7
20
 
8
21
  export const WIRE_DEFAULT_TOOLS = ["claude", "codex"];
9
22
  export const WIRE_TOOLS = [
@@ -99,6 +112,35 @@ function readText(path) {
99
112
  }
100
113
  }
101
114
 
115
+ // 파싱이 깨진 설정 파일은 덮어쓰지 않고 멈춘다(사용자 설정을 {}로 날리지 않게).
116
+ class InvalidConfigError extends Error {}
117
+
118
+ function parseJsonObject(text) {
119
+ if (!text || !text.trim()) return {};
120
+ let obj;
121
+ try {
122
+ obj = JSON.parse(text);
123
+ } catch {
124
+ throw new InvalidConfigError("invalid JSON");
125
+ }
126
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
127
+ throw new InvalidConfigError("not a JSON object");
128
+ }
129
+ return obj;
130
+ }
131
+
132
+ // 설정 파일을 읽어 변환한다. 깨졌으면 경로를 붙여 던지고, 호출자는 백업·쓰기 전에 이것을 쓴다.
133
+ function transformConfig(path, transform) {
134
+ try {
135
+ return transform(readText(path));
136
+ } catch (e) {
137
+ if (e instanceof InvalidConfigError) {
138
+ throw new Error(t("{path}: not valid JSON — fix or remove it, nothing was written", { path }));
139
+ }
140
+ throw e;
141
+ }
142
+ }
143
+
102
144
  export function writeSecretFile(path, contents, mode = 0o600) {
103
145
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
104
146
  const tmp = `${path}.pleum-tmp-${process.pid}`;
@@ -156,13 +198,7 @@ function removeRootValue(toml, name) {
156
198
 
157
199
  // ── Claude settings.json ──────────────────────────────────────────
158
200
  export function wireClaudeSettings(existingJson, { root, key, model } = {}) {
159
- let obj = {};
160
- if (existingJson && existingJson.trim()) {
161
- try {
162
- obj = JSON.parse(existingJson);
163
- } catch {}
164
- if (obj === null || typeof obj !== "object" || Array.isArray(obj)) obj = {};
165
- }
201
+ const obj = parseJsonObject(existingJson);
166
202
  obj.env = {
167
203
  ...(obj.env && typeof obj.env === "object" && !Array.isArray(obj.env) ? obj.env : {}),
168
204
  ANTHROPIC_BASE_URL: root,
@@ -193,14 +229,7 @@ export function wireClaudeSettings(existingJson, { root, key, model } = {}) {
193
229
  }
194
230
 
195
231
  export function unwireClaudeSettings(existingJson) {
196
- if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
197
- let obj = {};
198
- try {
199
- obj = JSON.parse(existingJson);
200
- } catch {}
201
- if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
202
- return `${JSON.stringify({}, null, 2)}\n`;
203
- }
232
+ const obj = parseJsonObject(existingJson);
204
233
  if (obj.env && typeof obj.env === "object" && !Array.isArray(obj.env)) {
205
234
  for (const envKey of CLAUDE_ENV_KEYS) delete obj.env[envKey];
206
235
  if (Object.keys(obj.env).length === 0) delete obj.env;
@@ -216,18 +245,21 @@ export function wireCodexToml(existingToml, { v1, key, model = "gpt-6.1-sol", bi
216
245
  const src = existingToml ?? "";
217
246
  const previousProvider = previousValue(src, "model_provider");
218
247
  const previousModel = previousValue(src, "model");
219
- const targetModel = billingOnly && previousModel ? previousModel : model;
220
248
 
221
249
  let body = stripWireBlock(src);
222
250
  body = removeTomlTable(body, "model_providers.pleum.http_headers");
223
251
  body = removeTomlTable(body, "model_providers.pleum");
224
252
  body = removeRootValue(removeRootValue(body, "model_provider"), "model");
225
253
 
254
+ // billing-only는 사용자 모델을 유지한다. previousModel은 이미 TOML 값("...")이라 따옴표를 다시 씌우지 않고,
255
+ // 원래 모델이 없었으면 model 줄을 생략해 Codex 기본값을 쓴다.
256
+ const modelLine = billingOnly
257
+ ? (previousModel ? `model = ${previousModel}\n` : "")
258
+ : `model = ${tomlBasicString(model)}\n`;
226
259
  const rootBlock = `${WIRE_MARKER_START}
227
260
  # previous_model_provider = ${previousProvider}
228
261
  # previous_model = ${previousModel}
229
- model = ${tomlBasicString(targetModel)}
230
- model_provider = "pleum"
262
+ ${modelLine}model_provider = "pleum"
231
263
  ${WIRE_MARKER_END}
232
264
  `;
233
265
  const providerBlock = `[model_providers.pleum]
@@ -260,13 +292,7 @@ export function unwireCodexToml(existingToml) {
260
292
 
261
293
  // ── OpenCode (opencode.json & auth.json) ──────────────────────────
262
294
  export function wireOpenCodeJson(existingJson, { v1, models = [], defaultModel } = {}) {
263
- let obj = {};
264
- if (existingJson && existingJson.trim()) {
265
- try {
266
- obj = JSON.parse(existingJson);
267
- } catch {}
268
- }
269
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
295
+ const obj = parseJsonObject(existingJson);
270
296
 
271
297
  obj.provider = obj.provider || {};
272
298
  const modelEntries = Object.fromEntries(models.map((id) => [id, {}]));
@@ -292,12 +318,7 @@ export function wireOpenCodeJson(existingJson, { v1, models = [], defaultModel }
292
318
  }
293
319
 
294
320
  export function unwireOpenCodeJson(existingJson) {
295
- if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
296
- let obj = {};
297
- try {
298
- obj = JSON.parse(existingJson);
299
- } catch {}
300
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
321
+ const obj = parseJsonObject(existingJson);
301
322
 
302
323
  if (obj.provider && obj.provider.pleum) {
303
324
  delete obj.provider.pleum;
@@ -315,24 +336,13 @@ export function unwireOpenCodeJson(existingJson) {
315
336
  }
316
337
 
317
338
  export function wireOpenCodeAuth(existingJson, key) {
318
- let obj = {};
319
- if (existingJson && existingJson.trim()) {
320
- try {
321
- obj = JSON.parse(existingJson);
322
- } catch {}
323
- }
324
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
339
+ const obj = parseJsonObject(existingJson);
325
340
  obj.pleum = { type: "api", key };
326
341
  return `${JSON.stringify(obj, null, 2)}\n`;
327
342
  }
328
343
 
329
344
  export function unwireOpenCodeAuth(existingJson) {
330
- if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
331
- let obj = {};
332
- try {
333
- obj = JSON.parse(existingJson);
334
- } catch {}
335
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
345
+ const obj = parseJsonObject(existingJson);
336
346
  delete obj.pleum;
337
347
  return `${JSON.stringify(obj, null, 2)}\n`;
338
348
  }
@@ -366,13 +376,7 @@ export function unwireAiderYaml(existingYaml = "") {
366
376
 
367
377
  // ── Crush ~/.config/crush/crush.json ──────────────────────────────
368
378
  export function wireCrushConfig(existingJson, { v1, key, models = [] } = {}) {
369
- let obj = {};
370
- if (existingJson && existingJson.trim()) {
371
- try {
372
- obj = JSON.parse(existingJson);
373
- } catch {}
374
- }
375
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
379
+ const obj = parseJsonObject(existingJson);
376
380
  obj.providers = obj.providers || {};
377
381
  obj.providers.pleum = {
378
382
  type: "openai-compat",
@@ -384,12 +388,7 @@ export function wireCrushConfig(existingJson, { v1, key, models = [] } = {}) {
384
388
  }
385
389
 
386
390
  export function unwireCrushConfig(existingJson) {
387
- if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
388
- let obj = {};
389
- try {
390
- obj = JSON.parse(existingJson);
391
- } catch {}
392
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
391
+ const obj = parseJsonObject(existingJson);
393
392
  if (obj.providers && obj.providers.pleum) {
394
393
  delete obj.providers.pleum;
395
394
  if (Object.keys(obj.providers).length === 0) delete obj.providers;
@@ -399,13 +398,7 @@ export function unwireCrushConfig(existingJson) {
399
398
 
400
399
  // ── Pi ~/.pi/agent/models.json ────────────────────────────────────
401
400
  export function wirePiConfig(existingJson, { v1, key, models = [] } = {}) {
402
- let obj = {};
403
- if (existingJson && existingJson.trim()) {
404
- try {
405
- obj = JSON.parse(existingJson);
406
- } catch {}
407
- }
408
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
401
+ const obj = parseJsonObject(existingJson);
409
402
  obj.providers = obj.providers || {};
410
403
  obj.providers.pleum = {
411
404
  baseUrl: v1,
@@ -417,12 +410,7 @@ export function wirePiConfig(existingJson, { v1, key, models = [] } = {}) {
417
410
  }
418
411
 
419
412
  export function unwirePiConfig(existingJson) {
420
- if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
421
- let obj = {};
422
- try {
423
- obj = JSON.parse(existingJson);
424
- } catch {}
425
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
413
+ const obj = parseJsonObject(existingJson);
426
414
  if (obj.providers && obj.providers.pleum) {
427
415
  delete obj.providers.pleum;
428
416
  if (Object.keys(obj.providers).length === 0) delete obj.providers;
@@ -432,45 +420,106 @@ export function unwirePiConfig(existingJson) {
432
420
 
433
421
  // ── Antigravity ~/.gemini/antigravity-cli/settings.json ────────────
434
422
  export function wireAgySettings(existingJson, { model } = {}) {
435
- let obj = {};
436
- if (existingJson && existingJson.trim()) {
437
- try {
438
- obj = JSON.parse(existingJson);
439
- } catch {}
440
- }
441
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
423
+ const obj = parseJsonObject(existingJson);
442
424
  obj.modelProvider = "gemini";
443
425
  if (model) obj.model = model;
444
426
  return `${JSON.stringify(obj, null, 2)}\n`;
445
427
  }
446
428
 
447
429
  export function unwireAgySettings(existingJson) {
448
- if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
449
- let obj = {};
450
- try {
451
- obj = JSON.parse(existingJson);
452
- } catch {}
453
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
430
+ const obj = parseJsonObject(existingJson);
454
431
  if (obj.modelProvider === "gemini") delete obj.modelProvider;
455
432
  delete obj.model;
456
433
  return `${JSON.stringify(obj, null, 2)}\n`;
457
434
  }
458
435
 
459
436
  // ── Shell function wrapper (rc files) ─────────────────────────────
460
- export function wireShellWrapper(existingRc = "", { tool, bin, envMap = {} } = {}) {
437
+ // POSIX single quotes. `'\''` ends the quote, inserts a literal quote, and reopens it.
438
+ export function posixSingleQuote(value) {
439
+ return "'" + String(value).replace(/'/g, "'\\''") + "'";
440
+ }
441
+
442
+ export function formatShellEnv(envMap = {}) {
443
+ const lines = Object.entries(envMap).map(
444
+ ([key, value]) => `export ${key}=${posixSingleQuote(value)}`,
445
+ );
446
+ return lines.length ? `${lines.join("\n")}\n` : "";
447
+ }
448
+
449
+ function shellEnvFile(paths, tool) {
450
+ const safe = String(tool).replace(/[^A-Za-z0-9._-]/g, "_");
451
+ return join(paths.home, ".pleum", "wire-env", `${safe}.env`);
452
+ }
453
+
454
+ function shellRcTarget(path) {
455
+ let st;
456
+ try {
457
+ st = lstatSync(path);
458
+ } catch {
459
+ return { dest: path, mode: 0o600, present: false };
460
+ }
461
+ if (!st.isSymbolicLink()) {
462
+ return { dest: path, mode: st.mode & 0o777, present: true };
463
+ }
464
+ let dest;
465
+ try {
466
+ dest = realpathSync(path);
467
+ } catch {
468
+ const link = readlinkSync(path);
469
+ dest = isAbsolute(link) ? link : resolve(dirname(path), link);
470
+ }
471
+ let mode = 0o600;
472
+ try {
473
+ mode = statSync(dest).mode & 0o777;
474
+ } catch {
475
+ // Dangling link: the file we create is new, so it gets the new-file mode.
476
+ }
477
+ return { dest, mode, present: true };
478
+ }
479
+
480
+ function readShellRc(path) {
481
+ const { dest, present } = shellRcTarget(path);
482
+ if (!present) return "";
483
+ return readText(dest) || "";
484
+ }
485
+
486
+ function writeShellRc(path, contents) {
487
+ // Rename onto the symlink path would replace the link. Write the target.
488
+ const { dest, mode } = shellRcTarget(path);
489
+ writeSecretFile(dest, contents, mode);
490
+ }
491
+
492
+ function wireShellTool(paths, tool, { bin, envMap }) {
493
+ const envFile = shellEnvFile(paths, tool);
494
+ writeSecretFile(envFile, formatShellEnv(envMap));
495
+ const nextRc = wireShellWrapper(readShellRc(paths.shellRc), { tool, bin, envFile });
496
+ writeShellRc(paths.shellRc, nextRc);
497
+ }
498
+
499
+ function unwireShellTool(paths, tool) {
500
+ if (shellRcTarget(paths.shellRc).present) {
501
+ writeShellRc(paths.shellRc, unwireShellWrapper(readShellRc(paths.shellRc), tool));
502
+ }
503
+ try {
504
+ unlinkSync(shellEnvFile(paths, tool));
505
+ } catch {
506
+ // Already removed.
507
+ }
508
+ }
509
+
510
+ export function wireShellWrapper(existingRc = "", { tool, bin, envFile } = {}) {
461
511
  const startMarker = `# pleum-wire:${tool}-start`;
462
512
  const endMarker = `# pleum-wire:${tool}-end`;
463
513
  const clean = (existingRc || "")
464
514
  .replace(new RegExp(`${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g"), "")
465
515
  .trim();
466
516
 
467
- const envAssignments = Object.entries(envMap)
468
- .map(([k, v]) => `${k}=${JSON.stringify(String(v))}`)
469
- .join(" ");
470
-
517
+ const source = envFile ? `. ${posixSingleQuote(envFile)}\n ` : "";
471
518
  const block = `${startMarker}
472
519
  ${tool}() {
473
- ${envAssignments} command ${bin || tool} "$@"
520
+ (
521
+ ${source}command ${bin || tool} "$@"
522
+ )
474
523
  }
475
524
  ${endMarker}`;
476
525
 
@@ -612,13 +661,34 @@ function parseJsonBody(res) {
612
661
  return res.json().catch(() => ({}));
613
662
  }
614
663
 
664
+ // 개인 과금은 소유자의 기본 공간이다. 조회가 실패하거나 없으면 null.
665
+ async function findDefaultWorkspaceId(auth) {
666
+ try {
667
+ const res = await auth("/workspaces");
668
+ if (!res.ok) return null;
669
+ const data = await parseJsonBody(res);
670
+ const items = Array.isArray(data) ? data : [];
671
+ return items.find((w) => w.is_default === true && w.role === "owner")?.id ?? null;
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+
677
+ // 개인 키 레코드에는 workspace_id 필드를 두지 않는다(공유 공간일 때만 남긴다).
678
+ function withWorkspace(record, workspaceId) {
679
+ const next = { ...record };
680
+ delete next.workspace_id;
681
+ if (workspaceId) next.workspace_id = workspaceId;
682
+ return next;
683
+ }
684
+
615
685
  export async function ensureWireKey({
616
686
  auth = authFetch,
617
687
  paths = wirePaths(),
618
688
  activeContext = getActiveContext(),
619
689
  } = {}) {
620
- const targetWorkspaceId =
621
- activeContext?.type === "workspace" ? activeContext.workspace_id : null;
690
+ // 과금 귀속은 키의 workspace_id가 정한다. 공유 공간이면 그 공간, 개인이면 소유자의 기본 공간.
691
+ const shared = activeContext?.type === "workspace" ? activeContext.workspace_id : null;
622
692
  const stored = (() => {
623
693
  try {
624
694
  return JSON.parse(readText(paths.wire) ?? "");
@@ -633,23 +703,23 @@ export async function ensureWireKey({
633
703
  const items = Array.isArray(data.items) ? data.items : [];
634
704
  const found = items.find((item) => String(item.id) === String(stored.id));
635
705
  if (found && found.is_active !== false) {
636
- if (
637
- targetWorkspaceId &&
638
- String(found.workspace_id ?? "") !== String(targetWorkspaceId)
639
- ) {
706
+ const target = shared ?? (await findDefaultWorkspaceId(auth));
707
+ const current = String(found.workspace_id ?? "");
708
+ if (target && current !== String(target)) {
640
709
  const moved = await auth(`/workspaces/keys/${stored.id}/move`, {
641
710
  method: "POST",
642
711
  headers: { "Content-Type": "application/json" },
643
- body: JSON.stringify({ target_workspace_id: targetWorkspaceId }),
712
+ body: JSON.stringify({ target_workspace_id: target }),
644
713
  });
645
714
  if (moved.ok) {
646
- const movedRecord = { ...stored, workspace_id: targetWorkspaceId };
715
+ const movedRecord = withWorkspace(stored, shared);
647
716
  writeSecretFile(paths.wire, `${JSON.stringify(movedRecord, null, 2)}\n`);
648
717
  return movedRecord;
649
718
  }
650
- } else {
719
+ } else if (target || !current) {
651
720
  return stored;
652
721
  }
722
+ // 그 밖(개인인데 기본 공간을 못 찾았고 키가 공유 공간에 묶인 경우)은 아래에서 새로 발급한다.
653
723
  }
654
724
  } else if (listed.status !== 404) {
655
725
  return stored;
@@ -659,9 +729,10 @@ export async function ensureWireKey({
659
729
  const created = await auth("/keys", {
660
730
  method: "POST",
661
731
  headers: { "Content-Type": "application/json" },
732
+ // workspace_id를 생략하면 서버가 소유자의 기본 공간에 키를 만든다.
662
733
  body: JSON.stringify({
663
734
  name: WIRE_KEY_NAME,
664
- ...(targetWorkspaceId ? { workspace_id: targetWorkspaceId } : {}),
735
+ ...(shared ? { workspace_id: shared } : {}),
665
736
  }),
666
737
  });
667
738
  if (!created.ok) {
@@ -675,7 +746,7 @@ export async function ensureWireKey({
675
746
  id: data.id,
676
747
  key: data.key,
677
748
  name: data.name ?? WIRE_KEY_NAME,
678
- ...(targetWorkspaceId ? { workspace_id: targetWorkspaceId } : {}),
749
+ ...(shared ? { workspace_id: shared } : {}),
679
750
  };
680
751
  writeSecretFile(paths.wire, `${JSON.stringify(record, null, 2)}\n`);
681
752
  return record;
@@ -742,8 +813,9 @@ export function applyToolWiring(tool, paths, { key, model, models = [], billingO
742
813
 
743
814
  switch (tool) {
744
815
  case "claude": {
816
+ // 파싱이 끝나야 백업·쓰기로 간다 — 깨진 파일이면 .pleum.bak도 남기지 않는다.
817
+ const next = transformConfig(paths.claude, (text) => wireClaudeSettings(text, { root: ROOT, key, model: billingOnly ? undefined : model }));
745
818
  backupOnce(paths.claude, paths.claudeBak);
746
- const next = wireClaudeSettings(readText(paths.claude), { root: ROOT, key, model: billingOnly ? undefined : model });
747
819
  writeSecretFile(paths.claude, next);
748
820
  console.log(t("Wrote {path}", { path: paths.claude }));
749
821
  break;
@@ -756,10 +828,10 @@ export function applyToolWiring(tool, paths, { key, model, models = [], billingO
756
828
  break;
757
829
  }
758
830
  case "opencode": {
831
+ const nextJson = transformConfig(paths.opencode, (text) => wireOpenCodeJson(text, { v1: V1, models, defaultModel: model }));
832
+ const nextAuth = transformConfig(paths.opencodeAuth, (text) => wireOpenCodeAuth(text, key));
759
833
  backupOnce(paths.opencode, paths.opencodeBak);
760
- const nextJson = wireOpenCodeJson(readText(paths.opencode), { v1: V1, models, defaultModel: model });
761
834
  writeSecretFile(paths.opencode, nextJson);
762
- const nextAuth = wireOpenCodeAuth(readText(paths.opencodeAuth), key);
763
835
  writeSecretFile(paths.opencodeAuth, nextAuth);
764
836
  console.log(t("Wrote {path}", { path: paths.opencode }));
765
837
  break;
@@ -772,26 +844,24 @@ export function applyToolWiring(tool, paths, { key, model, models = [], billingO
772
844
  break;
773
845
  }
774
846
  case "crush": {
847
+ const next = transformConfig(paths.crush, (text) => wireCrushConfig(text, { v1: V1, key, models }));
775
848
  backupOnce(paths.crush, paths.crushBak);
776
- const next = wireCrushConfig(readText(paths.crush), { v1: V1, key, models });
777
849
  writeSecretFile(paths.crush, next);
778
850
  console.log(t("Wrote {path}", { path: paths.crush }));
779
851
  break;
780
852
  }
781
853
  case "pi": {
854
+ const next = transformConfig(paths.pi, (text) => wirePiConfig(text, { v1: V1, key, models }));
782
855
  backupOnce(paths.pi, paths.piBak);
783
- const next = wirePiConfig(readText(paths.pi), { v1: V1, key, models });
784
856
  writeSecretFile(paths.pi, next);
785
857
  console.log(t("Wrote {path}", { path: paths.pi }));
786
858
  break;
787
859
  }
788
860
  case "agy": {
861
+ const nextAgy = transformConfig(paths.agy, (text) => wireAgySettings(text, { model: billingOnly ? undefined : model }));
789
862
  backupOnce(paths.agy, paths.agyBak);
790
- const nextAgy = wireAgySettings(readText(paths.agy), { model: billingOnly ? undefined : model });
791
863
  writeSecretFile(paths.agy, nextAgy);
792
- const rcContent = readText(paths.shellRc) || "";
793
- const nextRc = wireShellWrapper(rcContent, {
794
- tool: "agy",
864
+ wireShellTool(paths, "agy", {
795
865
  bin: "agy",
796
866
  envMap: {
797
867
  GOOGLE_GEMINI_BASE_URL: ROOT,
@@ -799,17 +869,13 @@ export function applyToolWiring(tool, paths, { key, model, models = [], billingO
799
869
  GOOGLE_API_KEY: key,
800
870
  },
801
871
  });
802
- writeSecretFile(paths.shellRc, nextRc, 0o644);
803
872
  console.log(t("Wrote {path}", { path: paths.agy }));
804
873
  break;
805
874
  }
806
875
  default: {
807
876
  const r = RECIPES[tool];
808
877
  if (r) {
809
- const rcContent = readText(paths.shellRc) || "";
810
- const envMap = r.env(key, model);
811
- const nextRc = wireShellWrapper(rcContent, { tool, bin: r.bin, envMap });
812
- writeSecretFile(paths.shellRc, nextRc, 0o644);
878
+ wireShellTool(paths, tool, { bin: r.bin, envMap: r.env(key, model) });
813
879
  console.log(t("Wrote {path}", { path: paths.shellRc }));
814
880
  }
815
881
  break;
@@ -833,7 +899,7 @@ export function revertToolWiring(tool, paths) {
833
899
  switch (tool) {
834
900
  case "claude": {
835
901
  if (existsSync(paths.claude)) {
836
- writeSecretFile(paths.claude, unwireClaudeSettings(readText(paths.claude)));
902
+ writeSecretFile(paths.claude, transformConfig(paths.claude, unwireClaudeSettings));
837
903
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.claude }));
838
904
  }
839
905
  break;
@@ -846,12 +912,11 @@ export function revertToolWiring(tool, paths) {
846
912
  break;
847
913
  }
848
914
  case "opencode": {
849
- if (existsSync(paths.opencode)) {
850
- writeSecretFile(paths.opencode, unwireOpenCodeJson(readText(paths.opencode)));
851
- }
852
- if (existsSync(paths.opencodeAuth)) {
853
- writeSecretFile(paths.opencodeAuth, unwireOpenCodeAuth(readText(paths.opencodeAuth)));
854
- }
915
+ // 두 파일 모두 파싱이 끝난 뒤에 쓴다(하나만 깨졌을 때 반쪽만 바뀌지 않게).
916
+ const nextJson = existsSync(paths.opencode) ? transformConfig(paths.opencode, unwireOpenCodeJson) : null;
917
+ const nextAuth = existsSync(paths.opencodeAuth) ? transformConfig(paths.opencodeAuth, unwireOpenCodeAuth) : null;
918
+ if (nextJson !== null) writeSecretFile(paths.opencode, nextJson);
919
+ if (nextAuth !== null) writeSecretFile(paths.opencodeAuth, nextAuth);
855
920
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.opencode }));
856
921
  break;
857
922
  }
@@ -864,31 +929,30 @@ export function revertToolWiring(tool, paths) {
864
929
  }
865
930
  case "crush": {
866
931
  if (existsSync(paths.crush)) {
867
- writeSecretFile(paths.crush, unwireCrushConfig(readText(paths.crush)));
932
+ writeSecretFile(paths.crush, transformConfig(paths.crush, unwireCrushConfig));
868
933
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.crush }));
869
934
  }
870
935
  break;
871
936
  }
872
937
  case "pi": {
873
938
  if (existsSync(paths.pi)) {
874
- writeSecretFile(paths.pi, unwirePiConfig(readText(paths.pi)));
939
+ writeSecretFile(paths.pi, transformConfig(paths.pi, unwirePiConfig));
875
940
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.pi }));
876
941
  }
877
942
  break;
878
943
  }
879
944
  case "agy": {
880
945
  if (existsSync(paths.agy)) {
881
- writeSecretFile(paths.agy, unwireAgySettings(readText(paths.agy)));
882
- }
883
- if (existsSync(paths.shellRc)) {
884
- writeSecretFile(paths.shellRc, unwireShellWrapper(readText(paths.shellRc), "agy"), 0o644);
946
+ writeSecretFile(paths.agy, transformConfig(paths.agy, unwireAgySettings));
885
947
  }
948
+ unwireShellTool(paths, "agy");
886
949
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.agy }));
887
950
  break;
888
951
  }
889
952
  default: {
890
- if (existsSync(paths.shellRc)) {
891
- writeSecretFile(paths.shellRc, unwireShellWrapper(readText(paths.shellRc), tool), 0o644);
953
+ const hadRc = shellRcTarget(paths.shellRc).present;
954
+ unwireShellTool(paths, tool);
955
+ if (hadRc) {
892
956
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.shellRc }));
893
957
  }
894
958
  break;
@@ -899,42 +963,66 @@ export function revertToolWiring(tool, paths) {
899
963
  writeWiredState(paths, state);
900
964
  }
901
965
 
966
+ // 과금 공간은 wire 때 고른다. 대화형 터미널이면 묻고, 파이프·CI에서는 저장된 값을 쓴다.
967
+ async function pickBillingContext(auth) {
968
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === "function");
969
+ return interactive ? selectAndBindWorkspace({ auth }) : getActiveContext();
970
+ }
971
+
902
972
  export async function wire(args = [], {
903
973
  auth = authFetch,
904
974
  home = homedir(),
905
975
  model,
906
976
  models,
907
977
  billingOnly = false,
908
- activeContext = getActiveContext(),
978
+ activeContext,
909
979
  } = {}) {
910
980
  const parsed = parseWireArgs(args);
911
981
  const tools = parsed.tools;
912
982
  const isBilling = billingOnly || parsed.billingOnly;
913
983
  const targetModel = model || parsed.model;
914
984
  const paths = wirePaths(home);
915
- const record = await ensureWireKey({ auth, paths, activeContext });
985
+ // 메뉴 경로는 호출자가 이미 고른 값을 넘긴다(중복 질문 방지).
986
+ const billing = activeContext ?? (await pickBillingContext(auth));
987
+ const record = await ensureWireKey({ auth, paths, activeContext: billing });
916
988
 
989
+ // 한 도구가 깨져도 나머지는 배선한다. 실패 목록은 마지막에 한 번에 던져 exit 1이 되게 한다.
990
+ const failures = [];
917
991
  for (const tool of tools) {
918
- applyToolWiring(tool, paths, {
919
- key: record.key,
920
- model: targetModel,
921
- models,
922
- billingOnly: isBilling,
923
- activeContext,
924
- });
925
- console.log(t("Wired {tool}.", { tool }));
992
+ try {
993
+ applyToolWiring(tool, paths, {
994
+ key: record.key,
995
+ model: targetModel,
996
+ models,
997
+ billingOnly: isBilling,
998
+ activeContext: billing,
999
+ });
1000
+ console.log(t("Wired {tool}.", { tool }));
1001
+ } catch (e) {
1002
+ failures.push(e.message);
1003
+ }
926
1004
  }
927
1005
 
1006
+ console.log(`ℹ ${t("Credit workspace")}: ${workspaceLabel(billing)}`);
1007
+ console.log(t("The wire key is shared by all wired tools and launch, so a billing workspace change applies to all of them."));
928
1008
  printDesktopHints(record);
1009
+ if (failures.length) throw new Error(failures.join("\n"));
929
1010
  }
930
1011
 
931
1012
  export async function unwire(args = [], { auth = authFetch, home = homedir() } = {}) {
932
1013
  const { tools, revoke } = parseWireArgs(args);
933
1014
  const paths = wirePaths(home);
934
1015
 
1016
+ const failures = [];
935
1017
  for (const tool of tools) {
936
- revertToolWiring(tool, paths);
1018
+ try {
1019
+ revertToolWiring(tool, paths);
1020
+ } catch (e) {
1021
+ failures.push(e.message);
1022
+ }
937
1023
  }
1024
+ // 실패가 있으면 키는 폐기하지 않는다(고친 뒤 다시 unwire할 수 있게).
1025
+ if (failures.length) throw new Error(failures.join("\n"));
938
1026
 
939
1027
  if (!revoke) return;
940
1028
 
package/src/workspace.mjs CHANGED
@@ -62,7 +62,7 @@ export function formatWorkspaceList(context, candidates = []) {
62
62
  for (const candidate of candidates) {
63
63
  const mark = String(candidate.workspace_id) === activeId ? style.bold(style.plum("▸ ")) : " ";
64
64
  lines.push(
65
- `${mark}${candidate.workspace_id} ${sanitizeTerminalText(candidate.name)} (${candidate.role})`,
65
+ `${mark}${candidate.workspace_id} ${sanitizeTerminalText(candidate.name)} (${sanitizeTerminalText(candidate.role)})`,
66
66
  );
67
67
  }
68
68
  return lines.join("\n");
@@ -130,8 +130,8 @@ export async function selectAndBindWorkspace({
130
130
  type: "workspace",
131
131
  workspace_id: c.workspace_id,
132
132
  name: c.name,
133
- title: `${c.name} (${c.role})`,
134
- label: `${activeId === String(c.workspace_id) ? "✓ " : " "}${sanitizeTerminalText(c.name)} (${c.role})`,
133
+ title: `${sanitizeTerminalText(c.name)} (${sanitizeTerminalText(c.role)})`,
134
+ label: `${activeId === String(c.workspace_id) ? "✓ " : " "}${sanitizeTerminalText(c.name)} (${sanitizeTerminalText(c.role)})`,
135
135
  })),
136
136
  ];
137
137