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/README.md +1 -1
- package/bin/pleum.mjs +1 -1
- package/package.json +1 -1
- package/src/balance.mjs +38 -137
- package/src/config.mjs +5 -18
- package/src/launch.mjs +2 -6
- package/src/locales/es.mjs +9 -2
- package/src/locales/ja.mjs +9 -2
- package/src/locales/ko.mjs +9 -2
- package/src/locales/zh.mjs +9 -2
- package/src/menu.mjs +3 -5
- package/src/run.mjs +19 -2
- package/src/settings.mjs +3 -6
- package/src/wire.mjs +375 -164
- package/src/workspace.mjs +3 -3
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 {
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 (
|
|
209
|
-
|
|
282
|
+
if (previous.env && Object.keys(previous.env).length > 0) {
|
|
283
|
+
obj.env = { ...(isPlainObject(obj.env) ? obj.env : {}), ...previous.env };
|
|
210
284
|
}
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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((
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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:
|
|
786
|
+
body: JSON.stringify({ target_workspace_id: target }),
|
|
644
787
|
});
|
|
645
788
|
if (moved.ok) {
|
|
646
|
-
const movedRecord =
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
if (
|
|
853
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
891
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
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) ?? "");
|