pleumrouter 0.5.45 → 0.5.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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}`;
@@ -154,15 +196,52 @@ function removeRootValue(toml, name) {
154
196
  return root.replace(new RegExp(`^${name}\\s*=\\s*.+\\n?`, "m"), "") + rest;
155
197
  }
156
198
 
199
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
200
+
201
+ // 블록을 지운 뒤 남는 구분 빈 줄(wire가 붙인 것)을 정리한다: 끝의 연속 줄바꿈은 하나로, 남은 게 없으면 "".
202
+ function tidyUnwired(text) {
203
+ const out = text.trimStart().replace(/\n{2,}$/, "\n");
204
+ return out.trim() ? out : "";
205
+ }
206
+
207
+ // 키 순서를 order에 맞춰 되살린다. 사라진 키는 건너뛰고, 그 뒤에 생긴 키는 현재 순서로 뒤에 붙는다.
208
+ function reorder(obj, order = []) {
209
+ const out = {};
210
+ for (const key of order) if (Object.hasOwn(obj, key)) out[key] = obj[key];
211
+ return Object.assign(out, obj);
212
+ }
213
+
214
+ // pleum이 배선한 Claude env인가: 게이트웨이가 우리 ROOT이거나 토큰이 plm_ 키.
215
+ function claudeEnvWired(env) {
216
+ const base = String(env?.ANTHROPIC_BASE_URL ?? "").replace(/\/+$/, "");
217
+ return base === ROOT || String(env?.ANTHROPIC_AUTH_TOKEN ?? "").startsWith("plm_");
218
+ }
219
+
220
+ // wire가 덮어쓰기 전의 사용자 값(claude·agy). 이미 pleum이 배선한 텍스트는 pleum 값을 원본으로 오인하므로 null.
221
+ // agy의 "gemini" provider는 launch.mjs와 같이 pleum 잔재로 본다.
222
+ function pickPrevious(tool, text) {
223
+ let obj;
224
+ try {
225
+ obj = parseJsonObject(text);
226
+ } catch {
227
+ return null;
228
+ }
229
+ if (tool === "agy") {
230
+ return obj.modelProvider === "gemini" ? null : { model: obj.model, modelProvider: obj.modelProvider };
231
+ }
232
+ if (claudeEnvWired(obj.env)) return null;
233
+ const env = isPlainObject(obj.env) ? obj.env : undefined;
234
+ return {
235
+ env: Object.fromEntries(CLAUDE_ENV_KEYS.filter((k) => env?.[k] !== undefined).map((k) => [k, env[k]])),
236
+ model: obj.model,
237
+ order: Object.keys(obj),
238
+ envOrder: env && Object.keys(env),
239
+ };
240
+ }
241
+
157
242
  // ── Claude settings.json ──────────────────────────────────────────
158
243
  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
- }
244
+ const obj = parseJsonObject(existingJson);
166
245
  obj.env = {
167
246
  ...(obj.env && typeof obj.env === "object" && !Array.isArray(obj.env) ? obj.env : {}),
168
247
  ANTHROPIC_BASE_URL: root,
@@ -192,23 +271,28 @@ export function wireClaudeSettings(existingJson, { root, key, model } = {}) {
192
271
  return `${JSON.stringify(obj, null, 2)}\n`;
193
272
  }
194
273
 
195
- 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
- }
204
- if (obj.env && typeof obj.env === "object" && !Array.isArray(obj.env)) {
274
+ // previous: wire 전 사용자 값(pickPrevious). 없으면 pleum 키만 지운다.
275
+ export function unwireClaudeSettings(existingJson, previous = {}) {
276
+ const obj = parseJsonObject(existingJson);
277
+ // wire는 obj.model과 같은 값을 ANTHROPIC_CUSTOM_MODEL_OPTION에도 쓴다 — claude- 접두사가 없는 모델도 이걸로 알아본다.
278
+ const customModel = isPlainObject(obj.env) ? obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION : undefined;
279
+ if (isPlainObject(obj.env)) {
205
280
  for (const envKey of CLAUDE_ENV_KEYS) delete obj.env[envKey];
206
- if (Object.keys(obj.env).length === 0) delete obj.env;
207
281
  }
208
- if (obj.model && typeof obj.model === "string" && (obj.model.startsWith("anthropic/") || obj.model.startsWith("pleum/"))) {
209
- delete obj.model;
282
+ if (previous.env && Object.keys(previous.env).length > 0) {
283
+ obj.env = { ...(isPlainObject(obj.env) ? obj.env : {}), ...previous.env };
210
284
  }
211
- return `${JSON.stringify(obj, null, 2)}\n`;
285
+ if (isPlainObject(obj.env)) {
286
+ obj.env = reorder(obj.env, previous.envOrder);
287
+ if (Object.keys(obj.env).length === 0 && !previous.envOrder) delete obj.env;
288
+ }
289
+ // model은 pleum이 넣은 값이거나 billing wire가 지워 비어 있을 때만 되돌린다 — 그 뒤 사용자가 바꾼 값은 둔다.
290
+ const pleumModel = typeof obj.model === "string" && (obj.model.startsWith("anthropic/") || obj.model.startsWith("pleum/") || obj.model === customModel);
291
+ if (pleumModel || obj.model === undefined) {
292
+ if (previous.model === undefined) delete obj.model;
293
+ else obj.model = previous.model;
294
+ }
295
+ return `${JSON.stringify(reorder(obj, previous.order), null, 2)}\n`;
212
296
  }
213
297
 
214
298
  // ── Codex config.toml ─────────────────────────────────────────────
@@ -216,18 +300,21 @@ export function wireCodexToml(existingToml, { v1, key, model = "gpt-6.1-sol", bi
216
300
  const src = existingToml ?? "";
217
301
  const previousProvider = previousValue(src, "model_provider");
218
302
  const previousModel = previousValue(src, "model");
219
- const targetModel = billingOnly && previousModel ? previousModel : model;
220
303
 
221
304
  let body = stripWireBlock(src);
222
305
  body = removeTomlTable(body, "model_providers.pleum.http_headers");
223
306
  body = removeTomlTable(body, "model_providers.pleum");
224
307
  body = removeRootValue(removeRootValue(body, "model_provider"), "model");
225
308
 
309
+ // billing-only는 사용자 모델을 유지한다. previousModel은 이미 TOML 값("...")이라 따옴표를 다시 씌우지 않고,
310
+ // 원래 모델이 없었으면 model 줄을 생략해 Codex 기본값을 쓴다.
311
+ const modelLine = billingOnly
312
+ ? (previousModel ? `model = ${previousModel}\n` : "")
313
+ : `model = ${tomlBasicString(model)}\n`;
226
314
  const rootBlock = `${WIRE_MARKER_START}
227
315
  # previous_model_provider = ${previousProvider}
228
316
  # previous_model = ${previousModel}
229
- model = ${tomlBasicString(targetModel)}
230
- model_provider = "pleum"
317
+ ${modelLine}model_provider = "pleum"
231
318
  ${WIRE_MARKER_END}
232
319
  `;
233
320
  const providerBlock = `[model_providers.pleum]
@@ -255,18 +342,15 @@ export function unwireCodexToml(existingToml) {
255
342
  previousModel ? `model = ${previousModel}` : "",
256
343
  previousProvider ? `model_provider = ${previousProvider}` : "",
257
344
  ].filter(Boolean).join("\n");
258
- return `${restored ? `${restored}\n\n` : ""}${body.trim()}`.replace(/\n{3,}/g, "\n\n").trimStart() + "\n";
345
+ // 복원한 root 줄 바로 뒤가 root 키면 붙이고(원래 모양), 테이블이면 빈 줄 하나를 둔다.
346
+ const rest = body.trim();
347
+ const gap = !restored || !rest ? "" : rest.startsWith("[") ? "\n\n" : "\n";
348
+ return `${restored}${gap}${rest}`.replace(/\n{3,}/g, "\n\n").trimStart() + "\n";
259
349
  }
260
350
 
261
351
  // ── OpenCode (opencode.json & auth.json) ──────────────────────────
262
352
  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 = {};
353
+ const obj = parseJsonObject(existingJson);
270
354
 
271
355
  obj.provider = obj.provider || {};
272
356
  const modelEntries = Object.fromEntries(models.map((id) => [id, {}]));
@@ -292,12 +376,7 @@ export function wireOpenCodeJson(existingJson, { v1, models = [], defaultModel }
292
376
  }
293
377
 
294
378
  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`;
379
+ const obj = parseJsonObject(existingJson);
301
380
 
302
381
  if (obj.provider && obj.provider.pleum) {
303
382
  delete obj.provider.pleum;
@@ -315,24 +394,13 @@ export function unwireOpenCodeJson(existingJson) {
315
394
  }
316
395
 
317
396
  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 = {};
397
+ const obj = parseJsonObject(existingJson);
325
398
  obj.pleum = { type: "api", key };
326
399
  return `${JSON.stringify(obj, null, 2)}\n`;
327
400
  }
328
401
 
329
402
  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`;
403
+ const obj = parseJsonObject(existingJson);
336
404
  delete obj.pleum;
337
405
  return `${JSON.stringify(obj, null, 2)}\n`;
338
406
  }
@@ -359,20 +427,12 @@ export function wireAiderYaml(existingYaml = "", { v1, key, model } = {}) {
359
427
  }
360
428
 
361
429
  export function unwireAiderYaml(existingYaml = "") {
362
- return (existingYaml || "")
363
- .replace(new RegExp(`${WIRE_MARKER_START}[\\s\\S]*?${WIRE_MARKER_END}\\n?`, "g"), "")
364
- .trimStart();
430
+ return tidyUnwired((existingYaml || "").replace(new RegExp(`${WIRE_MARKER_START}[\\s\\S]*?${WIRE_MARKER_END}\\n?`, "g"), ""));
365
431
  }
366
432
 
367
433
  // ── Crush ~/.config/crush/crush.json ──────────────────────────────
368
434
  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 = {};
435
+ const obj = parseJsonObject(existingJson);
376
436
  obj.providers = obj.providers || {};
377
437
  obj.providers.pleum = {
378
438
  type: "openai-compat",
@@ -384,12 +444,7 @@ export function wireCrushConfig(existingJson, { v1, key, models = [] } = {}) {
384
444
  }
385
445
 
386
446
  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`;
447
+ const obj = parseJsonObject(existingJson);
393
448
  if (obj.providers && obj.providers.pleum) {
394
449
  delete obj.providers.pleum;
395
450
  if (Object.keys(obj.providers).length === 0) delete obj.providers;
@@ -399,13 +454,7 @@ export function unwireCrushConfig(existingJson) {
399
454
 
400
455
  // ── Pi ~/.pi/agent/models.json ────────────────────────────────────
401
456
  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 = {};
457
+ const obj = parseJsonObject(existingJson);
409
458
  obj.providers = obj.providers || {};
410
459
  obj.providers.pleum = {
411
460
  baseUrl: v1,
@@ -417,12 +466,7 @@ export function wirePiConfig(existingJson, { v1, key, models = [] } = {}) {
417
466
  }
418
467
 
419
468
  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`;
469
+ const obj = parseJsonObject(existingJson);
426
470
  if (obj.providers && obj.providers.pleum) {
427
471
  delete obj.providers.pleum;
428
472
  if (Object.keys(obj.providers).length === 0) delete obj.providers;
@@ -432,45 +476,114 @@ export function unwirePiConfig(existingJson) {
432
476
 
433
477
  // ── Antigravity ~/.gemini/antigravity-cli/settings.json ────────────
434
478
  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 = {};
479
+ const obj = parseJsonObject(existingJson);
442
480
  obj.modelProvider = "gemini";
443
481
  if (model) obj.model = model;
444
482
  return `${JSON.stringify(obj, null, 2)}\n`;
445
483
  }
446
484
 
447
- 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`;
454
- if (obj.modelProvider === "gemini") delete obj.modelProvider;
455
- delete obj.model;
485
+ // previous: wire 전 사용자 값. wiredModel: undefined=모름(옛 호출, model을 지운다), null=pleum이 model을 쓰지 않음, 문자열=pleum이 쓴 값.
486
+ // model은 pleum이 쓴 값일 때만 되돌린다.
487
+ export function unwireAgySettings(existingJson, previous = {}, wiredModel) {
488
+ const obj = parseJsonObject(existingJson);
489
+ if (obj.modelProvider === "gemini") {
490
+ if (previous.modelProvider === undefined) delete obj.modelProvider;
491
+ else obj.modelProvider = previous.modelProvider;
492
+ }
493
+ if (wiredModel === undefined || obj.model === wiredModel) {
494
+ if (previous.model === undefined) delete obj.model;
495
+ else obj.model = previous.model;
496
+ }
456
497
  return `${JSON.stringify(obj, null, 2)}\n`;
457
498
  }
458
499
 
459
500
  // ── Shell function wrapper (rc files) ─────────────────────────────
460
- export function wireShellWrapper(existingRc = "", { tool, bin, envMap = {} } = {}) {
501
+ // POSIX single quotes. `'\''` ends the quote, inserts a literal quote, and reopens it.
502
+ export function posixSingleQuote(value) {
503
+ return "'" + String(value).replace(/'/g, "'\\''") + "'";
504
+ }
505
+
506
+ export function formatShellEnv(envMap = {}) {
507
+ const lines = Object.entries(envMap).map(
508
+ ([key, value]) => `export ${key}=${posixSingleQuote(value)}`,
509
+ );
510
+ return lines.length ? `${lines.join("\n")}\n` : "";
511
+ }
512
+
513
+ function shellEnvFile(paths, tool) {
514
+ const safe = String(tool).replace(/[^A-Za-z0-9._-]/g, "_");
515
+ return join(paths.home, ".pleum", "wire-env", `${safe}.env`);
516
+ }
517
+
518
+ function shellRcTarget(path) {
519
+ let st;
520
+ try {
521
+ st = lstatSync(path);
522
+ } catch {
523
+ return { dest: path, mode: 0o600, present: false };
524
+ }
525
+ if (!st.isSymbolicLink()) {
526
+ return { dest: path, mode: st.mode & 0o777, present: true };
527
+ }
528
+ let dest;
529
+ try {
530
+ dest = realpathSync(path);
531
+ } catch {
532
+ const link = readlinkSync(path);
533
+ dest = isAbsolute(link) ? link : resolve(dirname(path), link);
534
+ }
535
+ let mode = 0o600;
536
+ try {
537
+ mode = statSync(dest).mode & 0o777;
538
+ } catch {
539
+ // Dangling link: the file we create is new, so it gets the new-file mode.
540
+ }
541
+ return { dest, mode, present: true };
542
+ }
543
+
544
+ function readShellRc(path) {
545
+ const { dest, present } = shellRcTarget(path);
546
+ if (!present) return "";
547
+ return readText(dest) || "";
548
+ }
549
+
550
+ function writeShellRc(path, contents) {
551
+ // Rename onto the symlink path would replace the link. Write the target.
552
+ const { dest, mode } = shellRcTarget(path);
553
+ writeSecretFile(dest, contents, mode);
554
+ }
555
+
556
+ function wireShellTool(paths, tool, { bin, envMap }) {
557
+ const envFile = shellEnvFile(paths, tool);
558
+ writeSecretFile(envFile, formatShellEnv(envMap));
559
+ const nextRc = wireShellWrapper(readShellRc(paths.shellRc), { tool, bin, envFile });
560
+ writeShellRc(paths.shellRc, nextRc);
561
+ }
562
+
563
+ function unwireShellTool(paths, tool) {
564
+ if (shellRcTarget(paths.shellRc).present) {
565
+ writeShellRc(paths.shellRc, unwireShellWrapper(readShellRc(paths.shellRc), tool));
566
+ }
567
+ try {
568
+ unlinkSync(shellEnvFile(paths, tool));
569
+ } catch {
570
+ // Already removed.
571
+ }
572
+ }
573
+
574
+ export function wireShellWrapper(existingRc = "", { tool, bin, envFile } = {}) {
461
575
  const startMarker = `# pleum-wire:${tool}-start`;
462
576
  const endMarker = `# pleum-wire:${tool}-end`;
463
577
  const clean = (existingRc || "")
464
578
  .replace(new RegExp(`${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g"), "")
465
579
  .trim();
466
580
 
467
- const envAssignments = Object.entries(envMap)
468
- .map(([k, v]) => `${k}=${JSON.stringify(String(v))}`)
469
- .join(" ");
470
-
581
+ const source = envFile ? `. ${posixSingleQuote(envFile)}\n ` : "";
471
582
  const block = `${startMarker}
472
583
  ${tool}() {
473
- ${envAssignments} command ${bin || tool} "$@"
584
+ (
585
+ ${source}command ${bin || tool} "$@"
586
+ )
474
587
  }
475
588
  ${endMarker}`;
476
589
 
@@ -480,9 +593,7 @@ ${endMarker}`;
480
593
  export function unwireShellWrapper(existingRc = "", tool) {
481
594
  const startMarker = `# pleum-wire:${tool}-start`;
482
595
  const endMarker = `# pleum-wire:${tool}-end`;
483
- return (existingRc || "")
484
- .replace(new RegExp(`${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g"), "")
485
- .trimStart();
596
+ return tidyUnwired((existingRc || "").replace(new RegExp(`${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g"), ""));
486
597
  }
487
598
 
488
599
  // ── Wired State Metadata (~/.pleum/wired-agents.json) ─────────────
@@ -501,13 +612,24 @@ export function writeWiredState(paths, state) {
501
612
 
502
613
  // ── Wire Status Check ─────────────────────────────────────────────
503
614
  export function getWireStatus(home = homedir()) {
504
- const paths = wirePaths(home);
615
+ return wireStatusOf(wirePaths(home));
616
+ }
617
+
618
+ // paths를 그대로 받아, revert가 쓰는 rc 경로와 같은 기준으로 판정한다.
619
+ function wireStatusOf(paths) {
505
620
  const state = readWiredState(paths);
506
621
  const status = {};
507
622
 
508
- // 1. Claude
623
+ // 1. Claude — 게이트웨이/토큰이 pleum 것일 때만(사용자 본인 게이트웨이는 배선 아님). 파싱 불가(JSONC 등)는 예전 텍스트 검사.
509
624
  const claudeText = readText(paths.claude);
510
- const claudeWired = Boolean(claudeText && claudeText.includes("ANTHROPIC_BASE_URL"));
625
+ const claudeWired = (() => {
626
+ if (!claudeText) return false;
627
+ try {
628
+ return claudeEnvWired(parseJsonObject(claudeText).env);
629
+ } catch {
630
+ return claudeText.includes("ANTHROPIC_BASE_URL");
631
+ }
632
+ })();
511
633
  status.claude = {
512
634
  tool: "claude",
513
635
  label: "Claude Code",
@@ -578,9 +700,10 @@ export function getWireStatus(home = homedir()) {
578
700
  };
579
701
 
580
702
  // 7. Antigravity (agy)
703
+ // settings의 gemini provider만으로는 사용자 본인 설정·launch 임시값과 구분이 안 되므로, rc 블록이나 배선 기록이 함께 있어야 한다.
581
704
  const agyText = readText(paths.agy);
582
705
  const rcText = readText(paths.shellRc) || "";
583
- const agyWired = Boolean((agyText && agyText.includes('"modelProvider": "gemini"')) || rcText.includes("# pleum-wire:agy-start"));
706
+ const agyWired = Boolean(rcText.includes("# pleum-wire:agy-start") || (state.agy && agyText?.includes('"modelProvider": "gemini"')));
584
707
  status.agy = {
585
708
  tool: "agy",
586
709
  label: "Antigravity CLI",
@@ -612,13 +735,34 @@ function parseJsonBody(res) {
612
735
  return res.json().catch(() => ({}));
613
736
  }
614
737
 
738
+ // 개인 과금은 소유자의 기본 공간이다. 조회가 실패하거나 없으면 null.
739
+ async function findDefaultWorkspaceId(auth) {
740
+ try {
741
+ const res = await auth("/workspaces");
742
+ if (!res.ok) return null;
743
+ const data = await parseJsonBody(res);
744
+ const items = Array.isArray(data) ? data : [];
745
+ return items.find((w) => w.is_default === true && w.role === "owner")?.id ?? null;
746
+ } catch {
747
+ return null;
748
+ }
749
+ }
750
+
751
+ // 개인 키 레코드에는 workspace_id 필드를 두지 않는다(공유 공간일 때만 남긴다).
752
+ function withWorkspace(record, workspaceId) {
753
+ const next = { ...record };
754
+ delete next.workspace_id;
755
+ if (workspaceId) next.workspace_id = workspaceId;
756
+ return next;
757
+ }
758
+
615
759
  export async function ensureWireKey({
616
760
  auth = authFetch,
617
761
  paths = wirePaths(),
618
762
  activeContext = getActiveContext(),
619
763
  } = {}) {
620
- const targetWorkspaceId =
621
- activeContext?.type === "workspace" ? activeContext.workspace_id : null;
764
+ // 과금 귀속은 키의 workspace_id가 정한다. 공유 공간이면 그 공간, 개인이면 소유자의 기본 공간.
765
+ const shared = activeContext?.type === "workspace" ? activeContext.workspace_id : null;
622
766
  const stored = (() => {
623
767
  try {
624
768
  return JSON.parse(readText(paths.wire) ?? "");
@@ -633,23 +777,23 @@ export async function ensureWireKey({
633
777
  const items = Array.isArray(data.items) ? data.items : [];
634
778
  const found = items.find((item) => String(item.id) === String(stored.id));
635
779
  if (found && found.is_active !== false) {
636
- if (
637
- targetWorkspaceId &&
638
- String(found.workspace_id ?? "") !== String(targetWorkspaceId)
639
- ) {
780
+ const target = shared ?? (await findDefaultWorkspaceId(auth));
781
+ const current = String(found.workspace_id ?? "");
782
+ if (target && current !== String(target)) {
640
783
  const moved = await auth(`/workspaces/keys/${stored.id}/move`, {
641
784
  method: "POST",
642
785
  headers: { "Content-Type": "application/json" },
643
- body: JSON.stringify({ target_workspace_id: targetWorkspaceId }),
786
+ body: JSON.stringify({ target_workspace_id: target }),
644
787
  });
645
788
  if (moved.ok) {
646
- const movedRecord = { ...stored, workspace_id: targetWorkspaceId };
789
+ const movedRecord = withWorkspace(stored, shared);
647
790
  writeSecretFile(paths.wire, `${JSON.stringify(movedRecord, null, 2)}\n`);
648
791
  return movedRecord;
649
792
  }
650
- } else {
793
+ } else if (target || !current) {
651
794
  return stored;
652
795
  }
796
+ // 그 밖(개인인데 기본 공간을 못 찾았고 키가 공유 공간에 묶인 경우)은 아래에서 새로 발급한다.
653
797
  }
654
798
  } else if (listed.status !== 404) {
655
799
  return stored;
@@ -659,9 +803,10 @@ export async function ensureWireKey({
659
803
  const created = await auth("/keys", {
660
804
  method: "POST",
661
805
  headers: { "Content-Type": "application/json" },
806
+ // workspace_id를 생략하면 서버가 소유자의 기본 공간에 키를 만든다.
662
807
  body: JSON.stringify({
663
808
  name: WIRE_KEY_NAME,
664
- ...(targetWorkspaceId ? { workspace_id: targetWorkspaceId } : {}),
809
+ ...(shared ? { workspace_id: shared } : {}),
665
810
  }),
666
811
  });
667
812
  if (!created.ok) {
@@ -675,7 +820,7 @@ export async function ensureWireKey({
675
820
  id: data.id,
676
821
  key: data.key,
677
822
  name: data.name ?? WIRE_KEY_NAME,
678
- ...(targetWorkspaceId ? { workspace_id: targetWorkspaceId } : {}),
823
+ ...(shared ? { workspace_id: shared } : {}),
679
824
  };
680
825
  writeSecretFile(paths.wire, `${JSON.stringify(record, null, 2)}\n`);
681
826
  return record;
@@ -714,7 +859,8 @@ export function wireHelp() {
714
859
  }
715
860
 
716
861
  export function unwireHelp() {
717
- console.log(t("Usage: pleum unwire [claude|codex] [--revoke-key]\n"));
862
+ console.log(t("Usage: pleum unwire [tool...] [--revoke-key]\n"));
863
+ console.log(` ${t("Omit the tool name to unwire every wired tool.")}`);
718
864
  }
719
865
 
720
866
  function printDesktopHints(record) {
@@ -736,14 +882,32 @@ function printDesktopHints(record) {
736
882
  console.log(t("kept the desktop key on this machine (logout does not delete it)"));
737
883
  }
738
884
 
885
+ // 이 기록의 wire가 agy settings에 쓴 model: 문자열, 안 썼으면 null(billing·모델 없음), 기록이 없으면 undefined.
886
+ function agyWiredModel(record) {
887
+ return record ? (record.mode === "billing" ? null : (record.model ?? null)) : undefined;
888
+ }
889
+
890
+ // 이미 배선 기록이 있으면 그 원본을 그대로 쓴다(재배선이 pleum 값을 원본으로 덮지 않게). 0.5.45~46 기록(previous 없음)은
891
+ // 현재 파일이 이미 배선된 상태라 .pleum.bak에서, 기록이 없으면 현재 파일에서 뽑는다(배선된 파일이면 .pleum.bak).
892
+ function originalValues(tool, paths, state, text) {
893
+ if (state[tool]?.previous) return state[tool].previous;
894
+ const fromText = state[tool] ? null : pickPrevious(tool, text);
895
+ return fromText ?? pickPrevious(tool, readText(paths[`${tool}Bak`])) ?? {};
896
+ }
897
+
739
898
  export function applyToolWiring(tool, paths, { key, model, models = [], billingOnly = false, activeContext } = {}) {
740
899
  const state = readWiredState(paths);
741
900
  const mode = billingOnly ? "billing" : (models.length > 0 ? "list" : (model ? "custom" : "billing"));
901
+ let previous; // claude·agy가 덮어쓰는 사용자 원래 값 — unwire가 복원한다
742
902
 
743
903
  switch (tool) {
744
904
  case "claude": {
905
+ // 파싱이 끝나야 백업·쓰기로 간다 — 깨진 파일이면 .pleum.bak도 남기지 않는다.
906
+ const next = transformConfig(paths.claude, (text) => {
907
+ previous = originalValues(tool, paths, state, text);
908
+ return wireClaudeSettings(text, { root: ROOT, key, model: billingOnly ? undefined : model });
909
+ });
745
910
  backupOnce(paths.claude, paths.claudeBak);
746
- const next = wireClaudeSettings(readText(paths.claude), { root: ROOT, key, model: billingOnly ? undefined : model });
747
911
  writeSecretFile(paths.claude, next);
748
912
  console.log(t("Wrote {path}", { path: paths.claude }));
749
913
  break;
@@ -756,10 +920,10 @@ export function applyToolWiring(tool, paths, { key, model, models = [], billingO
756
920
  break;
757
921
  }
758
922
  case "opencode": {
923
+ const nextJson = transformConfig(paths.opencode, (text) => wireOpenCodeJson(text, { v1: V1, models, defaultModel: model }));
924
+ const nextAuth = transformConfig(paths.opencodeAuth, (text) => wireOpenCodeAuth(text, key));
759
925
  backupOnce(paths.opencode, paths.opencodeBak);
760
- const nextJson = wireOpenCodeJson(readText(paths.opencode), { v1: V1, models, defaultModel: model });
761
926
  writeSecretFile(paths.opencode, nextJson);
762
- const nextAuth = wireOpenCodeAuth(readText(paths.opencodeAuth), key);
763
927
  writeSecretFile(paths.opencodeAuth, nextAuth);
764
928
  console.log(t("Wrote {path}", { path: paths.opencode }));
765
929
  break;
@@ -772,26 +936,30 @@ export function applyToolWiring(tool, paths, { key, model, models = [], billingO
772
936
  break;
773
937
  }
774
938
  case "crush": {
939
+ const next = transformConfig(paths.crush, (text) => wireCrushConfig(text, { v1: V1, key, models }));
775
940
  backupOnce(paths.crush, paths.crushBak);
776
- const next = wireCrushConfig(readText(paths.crush), { v1: V1, key, models });
777
941
  writeSecretFile(paths.crush, next);
778
942
  console.log(t("Wrote {path}", { path: paths.crush }));
779
943
  break;
780
944
  }
781
945
  case "pi": {
946
+ const next = transformConfig(paths.pi, (text) => wirePiConfig(text, { v1: V1, key, models }));
782
947
  backupOnce(paths.pi, paths.piBak);
783
- const next = wirePiConfig(readText(paths.pi), { v1: V1, key, models });
784
948
  writeSecretFile(paths.pi, next);
785
949
  console.log(t("Wrote {path}", { path: paths.pi }));
786
950
  break;
787
951
  }
788
952
  case "agy": {
953
+ const writes = billingOnly ? undefined : model;
954
+ const nextAgy = transformConfig(paths.agy, (text) => {
955
+ previous = originalValues(tool, paths, state, text);
956
+ // model 없이 재배선하면 이전 wire가 쓴 pleum model을 걷어낸다(파일이 마지막 wire 기록과 어긋나지 않게).
957
+ const stale = agyWiredModel(state.agy);
958
+ return wireAgySettings(!writes && stale ? unwireAgySettings(text, previous, stale) : text, { model: writes });
959
+ });
789
960
  backupOnce(paths.agy, paths.agyBak);
790
- const nextAgy = wireAgySettings(readText(paths.agy), { model: billingOnly ? undefined : model });
791
961
  writeSecretFile(paths.agy, nextAgy);
792
- const rcContent = readText(paths.shellRc) || "";
793
- const nextRc = wireShellWrapper(rcContent, {
794
- tool: "agy",
962
+ wireShellTool(paths, "agy", {
795
963
  bin: "agy",
796
964
  envMap: {
797
965
  GOOGLE_GEMINI_BASE_URL: ROOT,
@@ -799,17 +967,13 @@ export function applyToolWiring(tool, paths, { key, model, models = [], billingO
799
967
  GOOGLE_API_KEY: key,
800
968
  },
801
969
  });
802
- writeSecretFile(paths.shellRc, nextRc, 0o644);
803
970
  console.log(t("Wrote {path}", { path: paths.agy }));
804
971
  break;
805
972
  }
806
973
  default: {
807
974
  const r = RECIPES[tool];
808
975
  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);
976
+ wireShellTool(paths, tool, { bin: r.bin, envMap: r.env(key, model) });
813
977
  console.log(t("Wrote {path}", { path: paths.shellRc }));
814
978
  }
815
979
  break;
@@ -823,17 +987,25 @@ export function applyToolWiring(tool, paths, { key, model, models = [], billingO
823
987
  models,
824
988
  workspace_id: activeContext?.workspace_id,
825
989
  workspace_name: activeContext?.workspace_name,
990
+ ...(previous && { previous }),
826
991
  };
827
992
  writeWiredState(paths, state);
828
993
  }
829
994
 
830
995
  export function revertToolWiring(tool, paths) {
831
996
  const state = readWiredState(paths);
997
+ // 기록도 없고 pleum이 배선한 흔적도 없으면 사용자 본인 설정이다 — 한 글자도 쓰지 않는다.
998
+ if (!state[tool] && !wireStatusOf(paths)[tool]?.wired) {
999
+ console.log(t("{tool} is not wired by PleumRouter; left unchanged.", { tool }));
1000
+ return;
1001
+ }
832
1002
 
833
1003
  switch (tool) {
834
1004
  case "claude": {
835
1005
  if (existsSync(paths.claude)) {
836
- writeSecretFile(paths.claude, unwireClaudeSettings(readText(paths.claude)));
1006
+ // 기록이 없으면(0.5.45~46) .pleum.bak에서 원래 값을 뽑는다.
1007
+ const previous = state.claude?.previous ?? pickPrevious("claude", readText(paths.claudeBak)) ?? {};
1008
+ writeSecretFile(paths.claude, transformConfig(paths.claude, (text) => unwireClaudeSettings(text, previous)));
837
1009
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.claude }));
838
1010
  }
839
1011
  break;
@@ -846,12 +1018,11 @@ export function revertToolWiring(tool, paths) {
846
1018
  break;
847
1019
  }
848
1020
  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
- }
1021
+ // 두 파일 모두 파싱이 끝난 뒤에 쓴다(하나만 깨졌을 때 반쪽만 바뀌지 않게).
1022
+ const nextJson = existsSync(paths.opencode) ? transformConfig(paths.opencode, unwireOpenCodeJson) : null;
1023
+ const nextAuth = existsSync(paths.opencodeAuth) ? transformConfig(paths.opencodeAuth, unwireOpenCodeAuth) : null;
1024
+ if (nextJson !== null) writeSecretFile(paths.opencode, nextJson);
1025
+ if (nextAuth !== null) writeSecretFile(paths.opencodeAuth, nextAuth);
855
1026
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.opencode }));
856
1027
  break;
857
1028
  }
@@ -864,31 +1035,31 @@ export function revertToolWiring(tool, paths) {
864
1035
  }
865
1036
  case "crush": {
866
1037
  if (existsSync(paths.crush)) {
867
- writeSecretFile(paths.crush, unwireCrushConfig(readText(paths.crush)));
1038
+ writeSecretFile(paths.crush, transformConfig(paths.crush, unwireCrushConfig));
868
1039
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.crush }));
869
1040
  }
870
1041
  break;
871
1042
  }
872
1043
  case "pi": {
873
1044
  if (existsSync(paths.pi)) {
874
- writeSecretFile(paths.pi, unwirePiConfig(readText(paths.pi)));
1045
+ writeSecretFile(paths.pi, transformConfig(paths.pi, unwirePiConfig));
875
1046
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.pi }));
876
1047
  }
877
1048
  break;
878
1049
  }
879
1050
  case "agy": {
880
1051
  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);
1052
+ const previous = state.agy?.previous ?? pickPrevious("agy", readText(paths.agyBak)) ?? {};
1053
+ writeSecretFile(paths.agy, transformConfig(paths.agy, (text) => unwireAgySettings(text, previous, agyWiredModel(state.agy))));
885
1054
  }
1055
+ unwireShellTool(paths, "agy");
886
1056
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.agy }));
887
1057
  break;
888
1058
  }
889
1059
  default: {
890
- if (existsSync(paths.shellRc)) {
891
- writeSecretFile(paths.shellRc, unwireShellWrapper(readText(paths.shellRc), tool), 0o644);
1060
+ const hadRc = shellRcTarget(paths.shellRc).present;
1061
+ unwireShellTool(paths, tool);
1062
+ if (hadRc) {
892
1063
  console.log(t("Removed PleumRouter settings from {path}.", { path: paths.shellRc }));
893
1064
  }
894
1065
  break;
@@ -899,45 +1070,85 @@ export function revertToolWiring(tool, paths) {
899
1070
  writeWiredState(paths, state);
900
1071
  }
901
1072
 
1073
+ // 과금 공간은 wire 때 고른다. 대화형 터미널이면 묻고, 파이프·CI에서는 저장된 값을 쓴다.
1074
+ async function pickBillingContext(auth) {
1075
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === "function");
1076
+ return interactive ? selectAndBindWorkspace({ auth }) : getActiveContext();
1077
+ }
1078
+
902
1079
  export async function wire(args = [], {
903
1080
  auth = authFetch,
904
1081
  home = homedir(),
905
1082
  model,
906
1083
  models,
907
1084
  billingOnly = false,
908
- activeContext = getActiveContext(),
1085
+ activeContext,
909
1086
  } = {}) {
910
1087
  const parsed = parseWireArgs(args);
911
1088
  const tools = parsed.tools;
912
1089
  const isBilling = billingOnly || parsed.billingOnly;
913
1090
  const targetModel = model || parsed.model;
914
1091
  const paths = wirePaths(home);
915
- const record = await ensureWireKey({ auth, paths, activeContext });
1092
+ // 메뉴 경로는 호출자가 이미 고른 값을 넘긴다(중복 질문 방지).
1093
+ const billing = activeContext ?? (await pickBillingContext(auth));
1094
+ const record = await ensureWireKey({ auth, paths, activeContext: billing });
916
1095
 
1096
+ // 한 도구가 깨져도 나머지는 배선한다. 실패 목록은 마지막에 한 번에 던져 exit 1이 되게 한다.
1097
+ const failures = [];
917
1098
  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 }));
1099
+ try {
1100
+ applyToolWiring(tool, paths, {
1101
+ key: record.key,
1102
+ model: targetModel,
1103
+ models,
1104
+ billingOnly: isBilling,
1105
+ activeContext: billing,
1106
+ });
1107
+ console.log(t("Wired {tool}.", { tool }));
1108
+ } catch (e) {
1109
+ failures.push(e.message);
1110
+ }
926
1111
  }
927
1112
 
1113
+ console.log(`ℹ ${t("Credit workspace")}: ${workspaceLabel(billing)}`);
1114
+ 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
1115
  printDesktopHints(record);
1116
+ if (failures.length) throw new Error(failures.join("\n"));
1117
+ }
1118
+
1119
+ // 배선된 도구: 감지된 것 ∪ 배선 기록(state)에 있는 것(WIRE_TOOLS 순).
1120
+ function wiredTools(paths) {
1121
+ const state = readWiredState(paths);
1122
+ const status = wireStatusOf(paths);
1123
+ return WIRE_TOOLS.filter((tool) => state[tool] || status[tool]?.wired);
929
1124
  }
930
1125
 
931
1126
  export async function unwire(args = [], { auth = authFetch, home = homedir() } = {}) {
932
- const { tools, revoke } = parseWireArgs(args);
1127
+ const { tools: named, revoke } = parseWireArgs(args);
933
1128
  const paths = wirePaths(home);
1129
+ // 도구를 안 적으면 wire 기본값(claude·codex)이 아니라 배선된 도구 전부 — 키를 같이 쓰기 때문이다.
1130
+ const tools = args.some((a) => WIRE_TOOLS.includes(a)) ? named : wiredTools(paths);
1131
+ if (tools.length === 0) console.log(t("No tools are wired by PleumRouter."));
934
1132
 
1133
+ const failures = [];
935
1134
  for (const tool of tools) {
936
- revertToolWiring(tool, paths);
1135
+ try {
1136
+ revertToolWiring(tool, paths);
1137
+ } catch (e) {
1138
+ failures.push(e.message);
1139
+ }
937
1140
  }
1141
+ // 실패가 있으면 키는 폐기하지 않는다(고친 뒤 다시 unwire할 수 있게).
1142
+ if (failures.length) throw new Error(failures.join("\n"));
938
1143
 
939
1144
  if (!revoke) return;
940
1145
 
1146
+ // 키는 모든 배선 도구가 공유한다 — 다른 도구가 남아 있으면 폐기하지 않는다(그 도구들이 죽은 키로 실패한다).
1147
+ const others = wiredTools(paths).filter((tool) => !tools.includes(tool));
1148
+ if (others.length) {
1149
+ throw new Error(t("Other tools still use this key: {tools}. Run `pleum unwire --revoke-key` to unwire all of them first.", { tools: others.join(", ") }));
1150
+ }
1151
+
941
1152
  const stored = (() => {
942
1153
  try {
943
1154
  return JSON.parse(readText(paths.wire) ?? "");