pleumrouter 0.5.44 → 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/bin/pleum.mjs +8 -3
- package/package.json +1 -1
- package/src/balance.mjs +38 -137
- package/src/config.mjs +5 -18
- package/src/interactive.mjs +130 -0
- package/src/launch.mjs +2 -6
- package/src/locales/es.mjs +41 -0
- package/src/locales/ja.mjs +41 -0
- package/src/locales/ko.mjs +41 -0
- package/src/locales/zh.mjs +41 -0
- package/src/menu.mjs +218 -5
- package/src/run.mjs +19 -2
- package/src/settings.mjs +3 -6
- package/src/wire.mjs +766 -70
- package/src/workspace.mjs +58 -1
package/src/wire.mjs
CHANGED
|
@@ -1,11 +1,46 @@
|
|
|
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
|
-
import { tomlBasicString } from "./recipes.mjs";
|
|
17
|
+
import { tomlBasicString, RECIPES } from "./recipes.mjs";
|
|
6
18
|
import { t } from "./i18n.mjs";
|
|
19
|
+
import { selectAndBindWorkspace, workspaceLabel } from "./workspace.mjs";
|
|
20
|
+
|
|
21
|
+
export const WIRE_DEFAULT_TOOLS = ["claude", "codex"];
|
|
22
|
+
export const WIRE_TOOLS = [
|
|
23
|
+
"claude",
|
|
24
|
+
"codex",
|
|
25
|
+
"opencode",
|
|
26
|
+
"aider",
|
|
27
|
+
"crush",
|
|
28
|
+
"pi",
|
|
29
|
+
"agy",
|
|
30
|
+
"gemini",
|
|
31
|
+
"qwen",
|
|
32
|
+
"copilot",
|
|
33
|
+
"goose",
|
|
34
|
+
"openhands",
|
|
35
|
+
"omp",
|
|
36
|
+
"hermes",
|
|
37
|
+
"minimax",
|
|
38
|
+
"mimo",
|
|
39
|
+
"openclaw",
|
|
40
|
+
"plandex",
|
|
41
|
+
"interpreter",
|
|
42
|
+
];
|
|
7
43
|
|
|
8
|
-
export const WIRE_TOOLS = ["claude", "codex"];
|
|
9
44
|
export const WIRE_KEY_NAME = "pleum-desktop";
|
|
10
45
|
export const WIRE_MARKER_START = "# pleum-wire-start";
|
|
11
46
|
export const WIRE_MARKER_END = "# pleum-wire-end";
|
|
@@ -17,11 +52,35 @@ const CLAUDE_ENV_KEYS = [
|
|
|
17
52
|
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
|
|
18
53
|
"API_TIMEOUT_MS",
|
|
19
54
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
|
55
|
+
"ANTHROPIC_CUSTOM_MODEL_OPTION",
|
|
56
|
+
"ANTHROPIC_CUSTOM_MODEL_OPTION_NAME",
|
|
57
|
+
"ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION",
|
|
58
|
+
"CLAUDE_CODE_SUBAGENT_MODEL",
|
|
59
|
+
"CLAUDE_CODE_SUBAGENT_MODEL_FORCE",
|
|
20
60
|
];
|
|
21
61
|
|
|
62
|
+
export const WIRE_WARNINGS = {
|
|
63
|
+
claude: "Once wired, running `claude` (CLI or Desktop) will route all requests through PleumRouter API credits instead of your Claude Pro/Max subscription until you run `pleum unwire claude`.",
|
|
64
|
+
codex: "Once wired, running `codex` (CLI or Desktop) will route all requests through PleumRouter API credits instead of your ChatGPT Plus/Pro plan or existing OpenAI key until you run `pleum unwire codex`.",
|
|
65
|
+
agy: "Once wired, Antigravity CLI (`agy`) will use PleumRouter Gemini API mode and deduct PleumRouter credits instead of Google account login until you run `pleum unwire agy`.",
|
|
66
|
+
opencode: "Once wired, OpenCode (CLI, Desktop, IDE) will register PleumRouter in ~/.config/opencode/opencode.json and charge PleumRouter credits when Pleum models are used.",
|
|
67
|
+
default: "Once wired, running `{tool}` will route API calls through PleumRouter and deduct PleumRouter credits until you run `pleum unwire {tool}`.",
|
|
68
|
+
};
|
|
69
|
+
|
|
22
70
|
export function wirePaths(home = homedir()) {
|
|
23
71
|
const claude = join(home, ".claude", "settings.json");
|
|
24
72
|
const codex = join(home, ".codex", "config.toml");
|
|
73
|
+
const opencode = join(home, ".config", "opencode", "opencode.json");
|
|
74
|
+
const opencodeAuth = join(home, ".local", "share", "opencode", "auth.json");
|
|
75
|
+
const aider = join(home, ".aider.conf.yml");
|
|
76
|
+
const crush = join(home, ".config", "crush", "crush.json");
|
|
77
|
+
const pi = join(home, ".pi", "agent", "models.json");
|
|
78
|
+
const agy = join(home, ".gemini", "antigravity-cli", "settings.json");
|
|
79
|
+
const shellRc = process.env.SHELL && process.env.SHELL.includes("bash")
|
|
80
|
+
? join(home, ".bashrc")
|
|
81
|
+
: join(home, ".zshrc");
|
|
82
|
+
const state = join(home, ".pleum", "wired-agents.json");
|
|
83
|
+
|
|
25
84
|
return {
|
|
26
85
|
home,
|
|
27
86
|
wire: join(home, ".pleum", "wire.json"),
|
|
@@ -29,6 +88,19 @@ export function wirePaths(home = homedir()) {
|
|
|
29
88
|
claudeBak: `${claude}.pleum.bak`,
|
|
30
89
|
codex,
|
|
31
90
|
codexBak: `${codex}.pleum.bak`,
|
|
91
|
+
opencode,
|
|
92
|
+
opencodeBak: `${opencode}.pleum.bak`,
|
|
93
|
+
opencodeAuth,
|
|
94
|
+
aider,
|
|
95
|
+
aiderBak: `${aider}.pleum.bak`,
|
|
96
|
+
crush,
|
|
97
|
+
crushBak: `${crush}.pleum.bak`,
|
|
98
|
+
pi,
|
|
99
|
+
piBak: `${pi}.pleum.bak`,
|
|
100
|
+
agy,
|
|
101
|
+
agyBak: `${agy}.pleum.bak`,
|
|
102
|
+
shellRc,
|
|
103
|
+
state,
|
|
32
104
|
};
|
|
33
105
|
}
|
|
34
106
|
|
|
@@ -40,17 +112,44 @@ function readText(path) {
|
|
|
40
112
|
}
|
|
41
113
|
}
|
|
42
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
|
+
|
|
43
144
|
export function writeSecretFile(path, contents, mode = 0o600) {
|
|
44
145
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
45
|
-
// 기존 파일이 0644 등으로 있으면 writeFileSync의 mode는 무시된다(#41) —
|
|
46
|
-
// tmp(강제 chmod) + rename 원자적 교체로 결과물이 항상 mode가 되게 한다.
|
|
47
146
|
const tmp = `${path}.pleum-tmp-${process.pid}`;
|
|
48
147
|
writeFileSync(tmp, contents, { mode });
|
|
49
148
|
try {
|
|
50
149
|
chmodSync(tmp, mode);
|
|
51
150
|
renameSync(tmp, path);
|
|
52
151
|
} catch (e) {
|
|
53
|
-
try { unlinkSync(tmp); } catch {}
|
|
152
|
+
try { unlinkSync(tmp); } catch {}
|
|
54
153
|
throw e;
|
|
55
154
|
}
|
|
56
155
|
}
|
|
@@ -97,12 +196,9 @@ function removeRootValue(toml, name) {
|
|
|
97
196
|
return root.replace(new RegExp(`^${name}\\s*=\\s*.+\\n?`, "m"), "") + rest;
|
|
98
197
|
}
|
|
99
198
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
obj = JSON.parse(existingJson);
|
|
104
|
-
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) obj = {};
|
|
105
|
-
}
|
|
199
|
+
// ── Claude settings.json ──────────────────────────────────────────
|
|
200
|
+
export function wireClaudeSettings(existingJson, { root, key, model } = {}) {
|
|
201
|
+
const obj = parseJsonObject(existingJson);
|
|
106
202
|
obj.env = {
|
|
107
203
|
...(obj.env && typeof obj.env === "object" && !Array.isArray(obj.env) ? obj.env : {}),
|
|
108
204
|
ANTHROPIC_BASE_URL: root,
|
|
@@ -111,35 +207,59 @@ export function wireClaudeSettings(existingJson, { root, key }) {
|
|
|
111
207
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
112
208
|
};
|
|
113
209
|
delete obj.env.ANTHROPIC_API_KEY;
|
|
210
|
+
|
|
211
|
+
if (model) {
|
|
212
|
+
const qualified = /^claude-/.test(model) ? `anthropic/${model}` : model;
|
|
213
|
+
obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION = qualified;
|
|
214
|
+
obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = `Pleum · ${model}`;
|
|
215
|
+
obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = "Selected in Pleum wire";
|
|
216
|
+
obj.env.CLAUDE_CODE_SUBAGENT_MODEL = "sonnet";
|
|
217
|
+
obj.env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE = "1";
|
|
218
|
+
obj.model = qualified;
|
|
219
|
+
} else {
|
|
220
|
+
delete obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
|
221
|
+
delete obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
|
|
222
|
+
delete obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
|
|
223
|
+
delete obj.env.CLAUDE_CODE_SUBAGENT_MODEL;
|
|
224
|
+
delete obj.env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE;
|
|
225
|
+
delete obj.model;
|
|
226
|
+
}
|
|
227
|
+
|
|
114
228
|
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
115
229
|
}
|
|
116
230
|
|
|
117
231
|
export function unwireClaudeSettings(existingJson) {
|
|
118
|
-
|
|
119
|
-
const obj = JSON.parse(existingJson);
|
|
120
|
-
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
121
|
-
return `${JSON.stringify({}, null, 2)}\n`;
|
|
122
|
-
}
|
|
232
|
+
const obj = parseJsonObject(existingJson);
|
|
123
233
|
if (obj.env && typeof obj.env === "object" && !Array.isArray(obj.env)) {
|
|
124
234
|
for (const envKey of CLAUDE_ENV_KEYS) delete obj.env[envKey];
|
|
125
235
|
if (Object.keys(obj.env).length === 0) delete obj.env;
|
|
126
236
|
}
|
|
237
|
+
if (obj.model && typeof obj.model === "string" && (obj.model.startsWith("anthropic/") || obj.model.startsWith("pleum/"))) {
|
|
238
|
+
delete obj.model;
|
|
239
|
+
}
|
|
127
240
|
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
128
241
|
}
|
|
129
242
|
|
|
130
|
-
|
|
243
|
+
// ── Codex config.toml ─────────────────────────────────────────────
|
|
244
|
+
export function wireCodexToml(existingToml, { v1, key, model = "gpt-6.1-sol", billingOnly = false } = {}) {
|
|
131
245
|
const src = existingToml ?? "";
|
|
132
246
|
const previousProvider = previousValue(src, "model_provider");
|
|
133
247
|
const previousModel = previousValue(src, "model");
|
|
248
|
+
|
|
134
249
|
let body = stripWireBlock(src);
|
|
135
250
|
body = removeTomlTable(body, "model_providers.pleum.http_headers");
|
|
136
251
|
body = removeTomlTable(body, "model_providers.pleum");
|
|
137
252
|
body = removeRootValue(removeRootValue(body, "model_provider"), "model");
|
|
253
|
+
|
|
254
|
+
// billing-only는 사용자 모델을 유지한다. previousModel은 이미 TOML 값("...")이라 따옴표를 다시 씌우지 않고,
|
|
255
|
+
// 원래 모델이 없었으면 model 줄을 생략해 Codex 기본값을 쓴다.
|
|
256
|
+
const modelLine = billingOnly
|
|
257
|
+
? (previousModel ? `model = ${previousModel}\n` : "")
|
|
258
|
+
: `model = ${tomlBasicString(model)}\n`;
|
|
138
259
|
const rootBlock = `${WIRE_MARKER_START}
|
|
139
260
|
# previous_model_provider = ${previousProvider}
|
|
140
261
|
# previous_model = ${previousModel}
|
|
141
|
-
|
|
142
|
-
model_provider = "pleum"
|
|
262
|
+
${modelLine}model_provider = "pleum"
|
|
143
263
|
${WIRE_MARKER_END}
|
|
144
264
|
`;
|
|
145
265
|
const providerBlock = `[model_providers.pleum]
|
|
@@ -170,17 +290,405 @@ export function unwireCodexToml(existingToml) {
|
|
|
170
290
|
return `${restored ? `${restored}\n\n` : ""}${body.trim()}`.replace(/\n{3,}/g, "\n\n").trimStart() + "\n";
|
|
171
291
|
}
|
|
172
292
|
|
|
293
|
+
// ── OpenCode (opencode.json & auth.json) ──────────────────────────
|
|
294
|
+
export function wireOpenCodeJson(existingJson, { v1, models = [], defaultModel } = {}) {
|
|
295
|
+
const obj = parseJsonObject(existingJson);
|
|
296
|
+
|
|
297
|
+
obj.provider = obj.provider || {};
|
|
298
|
+
const modelEntries = Object.fromEntries(models.map((id) => [id, {}]));
|
|
299
|
+
if (defaultModel) modelEntries[defaultModel] = {};
|
|
300
|
+
|
|
301
|
+
obj.provider.pleum = {
|
|
302
|
+
npm: "@ai-sdk/openai-compatible",
|
|
303
|
+
name: "PleumRouter",
|
|
304
|
+
options: {
|
|
305
|
+
baseURL: v1,
|
|
306
|
+
},
|
|
307
|
+
models: modelEntries,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
if (defaultModel) {
|
|
311
|
+
if (obj.model && !obj.model.startsWith("pleum/")) {
|
|
312
|
+
obj._pleum_previous_model = obj.model;
|
|
313
|
+
}
|
|
314
|
+
obj.model = `pleum/${defaultModel}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function unwireOpenCodeJson(existingJson) {
|
|
321
|
+
const obj = parseJsonObject(existingJson);
|
|
322
|
+
|
|
323
|
+
if (obj.provider && obj.provider.pleum) {
|
|
324
|
+
delete obj.provider.pleum;
|
|
325
|
+
if (Object.keys(obj.provider).length === 0) delete obj.provider;
|
|
326
|
+
}
|
|
327
|
+
if (obj.model && typeof obj.model === "string" && obj.model.startsWith("pleum/")) {
|
|
328
|
+
if (obj._pleum_previous_model) {
|
|
329
|
+
obj.model = obj._pleum_previous_model;
|
|
330
|
+
delete obj._pleum_previous_model;
|
|
331
|
+
} else {
|
|
332
|
+
delete obj.model;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function wireOpenCodeAuth(existingJson, key) {
|
|
339
|
+
const obj = parseJsonObject(existingJson);
|
|
340
|
+
obj.pleum = { type: "api", key };
|
|
341
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function unwireOpenCodeAuth(existingJson) {
|
|
345
|
+
const obj = parseJsonObject(existingJson);
|
|
346
|
+
delete obj.pleum;
|
|
347
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Aider ~/.aider.conf.yml ───────────────────────────────────────
|
|
351
|
+
export function wireAiderYaml(existingYaml = "", { v1, key, model } = {}) {
|
|
352
|
+
const clean = (existingYaml || "").replace(
|
|
353
|
+
new RegExp(`${WIRE_MARKER_START}[\\s\\S]*?${WIRE_MARKER_END}\\n?`, "g"),
|
|
354
|
+
"",
|
|
355
|
+
).trim();
|
|
356
|
+
|
|
357
|
+
const lines = [
|
|
358
|
+
WIRE_MARKER_START,
|
|
359
|
+
`openai-api-base: ${v1}`,
|
|
360
|
+
`openai-api-key: ${key}`,
|
|
361
|
+
];
|
|
362
|
+
if (model) {
|
|
363
|
+
const cleanModel = model.replace(/^openai\//, "");
|
|
364
|
+
lines.push(`model: openai/${cleanModel}`);
|
|
365
|
+
}
|
|
366
|
+
lines.push(WIRE_MARKER_END);
|
|
367
|
+
|
|
368
|
+
return `${clean ? `${clean}\n\n` : ""}${lines.join("\n")}\n`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function unwireAiderYaml(existingYaml = "") {
|
|
372
|
+
return (existingYaml || "")
|
|
373
|
+
.replace(new RegExp(`${WIRE_MARKER_START}[\\s\\S]*?${WIRE_MARKER_END}\\n?`, "g"), "")
|
|
374
|
+
.trimStart();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Crush ~/.config/crush/crush.json ──────────────────────────────
|
|
378
|
+
export function wireCrushConfig(existingJson, { v1, key, models = [] } = {}) {
|
|
379
|
+
const obj = parseJsonObject(existingJson);
|
|
380
|
+
obj.providers = obj.providers || {};
|
|
381
|
+
obj.providers.pleum = {
|
|
382
|
+
type: "openai-compat",
|
|
383
|
+
base_url: v1,
|
|
384
|
+
api_key: key,
|
|
385
|
+
models: models.map((id) => ({ id, name: id })),
|
|
386
|
+
};
|
|
387
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function unwireCrushConfig(existingJson) {
|
|
391
|
+
const obj = parseJsonObject(existingJson);
|
|
392
|
+
if (obj.providers && obj.providers.pleum) {
|
|
393
|
+
delete obj.providers.pleum;
|
|
394
|
+
if (Object.keys(obj.providers).length === 0) delete obj.providers;
|
|
395
|
+
}
|
|
396
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Pi ~/.pi/agent/models.json ────────────────────────────────────
|
|
400
|
+
export function wirePiConfig(existingJson, { v1, key, models = [] } = {}) {
|
|
401
|
+
const obj = parseJsonObject(existingJson);
|
|
402
|
+
obj.providers = obj.providers || {};
|
|
403
|
+
obj.providers.pleum = {
|
|
404
|
+
baseUrl: v1,
|
|
405
|
+
api: "openai-completions",
|
|
406
|
+
apiKey: key,
|
|
407
|
+
models: models.map((id) => ({ id })),
|
|
408
|
+
};
|
|
409
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function unwirePiConfig(existingJson) {
|
|
413
|
+
const obj = parseJsonObject(existingJson);
|
|
414
|
+
if (obj.providers && obj.providers.pleum) {
|
|
415
|
+
delete obj.providers.pleum;
|
|
416
|
+
if (Object.keys(obj.providers).length === 0) delete obj.providers;
|
|
417
|
+
}
|
|
418
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Antigravity ~/.gemini/antigravity-cli/settings.json ────────────
|
|
422
|
+
export function wireAgySettings(existingJson, { model } = {}) {
|
|
423
|
+
const obj = parseJsonObject(existingJson);
|
|
424
|
+
obj.modelProvider = "gemini";
|
|
425
|
+
if (model) obj.model = model;
|
|
426
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function unwireAgySettings(existingJson) {
|
|
430
|
+
const obj = parseJsonObject(existingJson);
|
|
431
|
+
if (obj.modelProvider === "gemini") delete obj.modelProvider;
|
|
432
|
+
delete obj.model;
|
|
433
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── Shell function wrapper (rc files) ─────────────────────────────
|
|
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 } = {}) {
|
|
511
|
+
const startMarker = `# pleum-wire:${tool}-start`;
|
|
512
|
+
const endMarker = `# pleum-wire:${tool}-end`;
|
|
513
|
+
const clean = (existingRc || "")
|
|
514
|
+
.replace(new RegExp(`${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g"), "")
|
|
515
|
+
.trim();
|
|
516
|
+
|
|
517
|
+
const source = envFile ? `. ${posixSingleQuote(envFile)}\n ` : "";
|
|
518
|
+
const block = `${startMarker}
|
|
519
|
+
${tool}() {
|
|
520
|
+
(
|
|
521
|
+
${source}command ${bin || tool} "$@"
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
${endMarker}`;
|
|
525
|
+
|
|
526
|
+
return `${clean ? `${clean}\n\n` : ""}${block}\n`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function unwireShellWrapper(existingRc = "", tool) {
|
|
530
|
+
const startMarker = `# pleum-wire:${tool}-start`;
|
|
531
|
+
const endMarker = `# pleum-wire:${tool}-end`;
|
|
532
|
+
return (existingRc || "")
|
|
533
|
+
.replace(new RegExp(`${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g"), "")
|
|
534
|
+
.trimStart();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── Wired State Metadata (~/.pleum/wired-agents.json) ─────────────
|
|
538
|
+
export function readWiredState(paths) {
|
|
539
|
+
try {
|
|
540
|
+
const raw = readText(paths.state);
|
|
541
|
+
return raw ? JSON.parse(raw) : {};
|
|
542
|
+
} catch {
|
|
543
|
+
return {};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function writeWiredState(paths, state) {
|
|
548
|
+
writeSecretFile(paths.state, `${JSON.stringify(state, null, 2)}\n`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── Wire Status Check ─────────────────────────────────────────────
|
|
552
|
+
export function getWireStatus(home = homedir()) {
|
|
553
|
+
const paths = wirePaths(home);
|
|
554
|
+
const state = readWiredState(paths);
|
|
555
|
+
const status = {};
|
|
556
|
+
|
|
557
|
+
// 1. Claude
|
|
558
|
+
const claudeText = readText(paths.claude);
|
|
559
|
+
const claudeWired = Boolean(claudeText && claudeText.includes("ANTHROPIC_BASE_URL"));
|
|
560
|
+
status.claude = {
|
|
561
|
+
tool: "claude",
|
|
562
|
+
label: "Claude Code",
|
|
563
|
+
wired: claudeWired,
|
|
564
|
+
mode: state.claude?.mode || (claudeWired ? "billing" : undefined),
|
|
565
|
+
model: state.claude?.model,
|
|
566
|
+
targetPath: paths.claude,
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
// 2. Codex
|
|
570
|
+
const codexText = readText(paths.codex);
|
|
571
|
+
const codexWired = Boolean(codexText && codexText.includes('model_provider = "pleum"'));
|
|
572
|
+
status.codex = {
|
|
573
|
+
tool: "codex",
|
|
574
|
+
label: "Codex CLI",
|
|
575
|
+
wired: codexWired,
|
|
576
|
+
mode: state.codex?.mode || (codexWired ? "billing" : undefined),
|
|
577
|
+
model: state.codex?.model,
|
|
578
|
+
targetPath: paths.codex,
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// 3. OpenCode
|
|
582
|
+
const opencodeText = readText(paths.opencode);
|
|
583
|
+
const opencodeWired = Boolean(opencodeText && opencodeText.includes('"PleumRouter"'));
|
|
584
|
+
status.opencode = {
|
|
585
|
+
tool: "opencode",
|
|
586
|
+
label: "OpenCode",
|
|
587
|
+
wired: opencodeWired,
|
|
588
|
+
mode: state.opencode?.mode || (opencodeWired ? "list" : undefined),
|
|
589
|
+
model: state.opencode?.model,
|
|
590
|
+
targetPath: paths.opencode,
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// 4. Aider
|
|
594
|
+
const aiderText = readText(paths.aider);
|
|
595
|
+
const aiderWired = Boolean(aiderText && aiderText.includes("openai-api-base") && aiderText.includes("apirouter.pleum.ai"));
|
|
596
|
+
status.aider = {
|
|
597
|
+
tool: "aider",
|
|
598
|
+
label: "Aider",
|
|
599
|
+
wired: aiderWired,
|
|
600
|
+
mode: state.aider?.mode || (aiderWired ? "billing" : undefined),
|
|
601
|
+
model: state.aider?.model,
|
|
602
|
+
targetPath: paths.aider,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// 5. Crush
|
|
606
|
+
const crushText = readText(paths.crush);
|
|
607
|
+
const crushWired = Boolean(crushText && crushText.includes("apirouter.pleum.ai"));
|
|
608
|
+
status.crush = {
|
|
609
|
+
tool: "crush",
|
|
610
|
+
label: "Crush",
|
|
611
|
+
wired: crushWired,
|
|
612
|
+
mode: state.crush?.mode || (crushWired ? "list" : undefined),
|
|
613
|
+
model: state.crush?.model,
|
|
614
|
+
targetPath: paths.crush,
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// 6. Pi
|
|
618
|
+
const piText = readText(paths.pi);
|
|
619
|
+
const piWired = Boolean(piText && piText.includes("apirouter.pleum.ai"));
|
|
620
|
+
status.pi = {
|
|
621
|
+
tool: "pi",
|
|
622
|
+
label: "Pi",
|
|
623
|
+
wired: piWired,
|
|
624
|
+
mode: state.pi?.mode || (piWired ? "list" : undefined),
|
|
625
|
+
model: state.pi?.model,
|
|
626
|
+
targetPath: paths.pi,
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// 7. Antigravity (agy)
|
|
630
|
+
const agyText = readText(paths.agy);
|
|
631
|
+
const rcText = readText(paths.shellRc) || "";
|
|
632
|
+
const agyWired = Boolean((agyText && agyText.includes('"modelProvider": "gemini"')) || rcText.includes("# pleum-wire:agy-start"));
|
|
633
|
+
status.agy = {
|
|
634
|
+
tool: "agy",
|
|
635
|
+
label: "Antigravity CLI",
|
|
636
|
+
wired: agyWired,
|
|
637
|
+
mode: state.agy?.mode || (agyWired ? "billing" : undefined),
|
|
638
|
+
model: state.agy?.model,
|
|
639
|
+
targetPath: paths.agy,
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// Shell-based tools
|
|
643
|
+
for (const tool of WIRE_TOOLS) {
|
|
644
|
+
if (status[tool]) continue;
|
|
645
|
+
const r = RECIPES[tool];
|
|
646
|
+
const isWired = rcText.includes(`# pleum-wire:${tool}-start`);
|
|
647
|
+
status[tool] = {
|
|
648
|
+
tool,
|
|
649
|
+
label: r?.label || tool,
|
|
650
|
+
wired: isWired,
|
|
651
|
+
mode: state[tool]?.mode || (isWired ? "billing" : undefined),
|
|
652
|
+
model: state[tool]?.model,
|
|
653
|
+
targetPath: paths.shellRc,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return status;
|
|
658
|
+
}
|
|
659
|
+
|
|
173
660
|
function parseJsonBody(res) {
|
|
174
661
|
return res.json().catch(() => ({}));
|
|
175
662
|
}
|
|
176
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
|
+
|
|
177
685
|
export async function ensureWireKey({
|
|
178
686
|
auth = authFetch,
|
|
179
687
|
paths = wirePaths(),
|
|
180
688
|
activeContext = getActiveContext(),
|
|
181
689
|
} = {}) {
|
|
182
|
-
|
|
183
|
-
|
|
690
|
+
// 과금 귀속은 키의 workspace_id가 정한다. 공유 공간이면 그 공간, 개인이면 소유자의 기본 공간.
|
|
691
|
+
const shared = activeContext?.type === "workspace" ? activeContext.workspace_id : null;
|
|
184
692
|
const stored = (() => {
|
|
185
693
|
try {
|
|
186
694
|
return JSON.parse(readText(paths.wire) ?? "");
|
|
@@ -195,25 +703,23 @@ export async function ensureWireKey({
|
|
|
195
703
|
const items = Array.isArray(data.items) ? data.items : [];
|
|
196
704
|
const found = items.find((item) => String(item.id) === String(stored.id));
|
|
197
705
|
if (found && found.is_active !== false) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
) {
|
|
706
|
+
const target = shared ?? (await findDefaultWorkspaceId(auth));
|
|
707
|
+
const current = String(found.workspace_id ?? "");
|
|
708
|
+
if (target && current !== String(target)) {
|
|
202
709
|
const moved = await auth(`/workspaces/keys/${stored.id}/move`, {
|
|
203
710
|
method: "POST",
|
|
204
711
|
headers: { "Content-Type": "application/json" },
|
|
205
|
-
body: JSON.stringify({ target_workspace_id:
|
|
712
|
+
body: JSON.stringify({ target_workspace_id: target }),
|
|
206
713
|
});
|
|
207
714
|
if (moved.ok) {
|
|
208
|
-
const movedRecord =
|
|
715
|
+
const movedRecord = withWorkspace(stored, shared);
|
|
209
716
|
writeSecretFile(paths.wire, `${JSON.stringify(movedRecord, null, 2)}\n`);
|
|
210
717
|
return movedRecord;
|
|
211
718
|
}
|
|
212
|
-
|
|
213
|
-
// 아래 새 키 발급으로 공간 귀속 키를 만든다(과금 오귀속보다 키 재발급이 낫다).
|
|
214
|
-
} else {
|
|
719
|
+
} else if (target || !current) {
|
|
215
720
|
return stored;
|
|
216
721
|
}
|
|
722
|
+
// 그 밖(개인인데 기본 공간을 못 찾았고 키가 공유 공간에 묶인 경우)은 아래에서 새로 발급한다.
|
|
217
723
|
}
|
|
218
724
|
} else if (listed.status !== 404) {
|
|
219
725
|
return stored;
|
|
@@ -223,9 +729,10 @@ export async function ensureWireKey({
|
|
|
223
729
|
const created = await auth("/keys", {
|
|
224
730
|
method: "POST",
|
|
225
731
|
headers: { "Content-Type": "application/json" },
|
|
732
|
+
// workspace_id를 생략하면 서버가 소유자의 기본 공간에 키를 만든다.
|
|
226
733
|
body: JSON.stringify({
|
|
227
734
|
name: WIRE_KEY_NAME,
|
|
228
|
-
...(
|
|
735
|
+
...(shared ? { workspace_id: shared } : {}),
|
|
229
736
|
}),
|
|
230
737
|
});
|
|
231
738
|
if (!created.ok) {
|
|
@@ -239,21 +746,36 @@ export async function ensureWireKey({
|
|
|
239
746
|
id: data.id,
|
|
240
747
|
key: data.key,
|
|
241
748
|
name: data.name ?? WIRE_KEY_NAME,
|
|
242
|
-
...(
|
|
749
|
+
...(shared ? { workspace_id: shared } : {}),
|
|
243
750
|
};
|
|
244
751
|
writeSecretFile(paths.wire, `${JSON.stringify(record, null, 2)}\n`);
|
|
245
752
|
return record;
|
|
246
753
|
}
|
|
247
754
|
|
|
248
|
-
export function parseWireArgs(args) {
|
|
755
|
+
export function parseWireArgs(args = []) {
|
|
249
756
|
const revoke = args.includes("--revoke-key");
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
const
|
|
757
|
+
const billingOnly = args.includes("--billing-only");
|
|
758
|
+
let model;
|
|
759
|
+
const filtered = [];
|
|
760
|
+
for (let i = 0; i < args.length; i++) {
|
|
761
|
+
const a = args[i];
|
|
762
|
+
if (a === "--revoke-key" || a === "--billing-only") continue;
|
|
763
|
+
if (a === "--model" || a === "-m") {
|
|
764
|
+
model = args[++i];
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
filtered.push(a);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const unknown = filtered.find((tool) => !WIRE_TOOLS.includes(tool));
|
|
253
771
|
if (unknown) {
|
|
254
772
|
throw new Error(t("Unsupported tool: {tool}. Use claude or codex.", { tool: unknown }));
|
|
255
773
|
}
|
|
256
|
-
|
|
774
|
+
|
|
775
|
+
const res = { tools: filtered.length === 0 ? [...WIRE_DEFAULT_TOOLS] : [...new Set(filtered)], revoke };
|
|
776
|
+
if (billingOnly) res.billingOnly = true;
|
|
777
|
+
if (model) res.model = model;
|
|
778
|
+
return res;
|
|
257
779
|
}
|
|
258
780
|
|
|
259
781
|
export function wireHelp() {
|
|
@@ -285,47 +807,223 @@ function printDesktopHints(record) {
|
|
|
285
807
|
console.log(t("kept the desktop key on this machine (logout does not delete it)"));
|
|
286
808
|
}
|
|
287
809
|
|
|
288
|
-
function
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
writeSecretFile(paths.claude, next);
|
|
292
|
-
console.log(t("Wrote {path}", { path: paths.claude }));
|
|
293
|
-
}
|
|
810
|
+
export function applyToolWiring(tool, paths, { key, model, models = [], billingOnly = false, activeContext } = {}) {
|
|
811
|
+
const state = readWiredState(paths);
|
|
812
|
+
const mode = billingOnly ? "billing" : (models.length > 0 ? "list" : (model ? "custom" : "billing"));
|
|
294
813
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
814
|
+
switch (tool) {
|
|
815
|
+
case "claude": {
|
|
816
|
+
// 파싱이 끝나야 백업·쓰기로 간다 — 깨진 파일이면 .pleum.bak도 남기지 않는다.
|
|
817
|
+
const next = transformConfig(paths.claude, (text) => wireClaudeSettings(text, { root: ROOT, key, model: billingOnly ? undefined : model }));
|
|
818
|
+
backupOnce(paths.claude, paths.claudeBak);
|
|
819
|
+
writeSecretFile(paths.claude, next);
|
|
820
|
+
console.log(t("Wrote {path}", { path: paths.claude }));
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
case "codex": {
|
|
824
|
+
backupOnce(paths.codex, paths.codexBak);
|
|
825
|
+
const next = wireCodexToml(readText(paths.codex) ?? "", { v1: V1, key, model: model || "gpt-6.1-sol", billingOnly });
|
|
826
|
+
writeSecretFile(paths.codex, next);
|
|
827
|
+
console.log(t("Wrote {path}", { path: paths.codex }));
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
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));
|
|
833
|
+
backupOnce(paths.opencode, paths.opencodeBak);
|
|
834
|
+
writeSecretFile(paths.opencode, nextJson);
|
|
835
|
+
writeSecretFile(paths.opencodeAuth, nextAuth);
|
|
836
|
+
console.log(t("Wrote {path}", { path: paths.opencode }));
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
case "aider": {
|
|
840
|
+
backupOnce(paths.aider, paths.aiderBak);
|
|
841
|
+
const next = wireAiderYaml(readText(paths.aider), { v1: V1, key, model: billingOnly ? undefined : model });
|
|
842
|
+
writeSecretFile(paths.aider, next);
|
|
843
|
+
console.log(t("Wrote {path}", { path: paths.aider }));
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
case "crush": {
|
|
847
|
+
const next = transformConfig(paths.crush, (text) => wireCrushConfig(text, { v1: V1, key, models }));
|
|
848
|
+
backupOnce(paths.crush, paths.crushBak);
|
|
849
|
+
writeSecretFile(paths.crush, next);
|
|
850
|
+
console.log(t("Wrote {path}", { path: paths.crush }));
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
case "pi": {
|
|
854
|
+
const next = transformConfig(paths.pi, (text) => wirePiConfig(text, { v1: V1, key, models }));
|
|
855
|
+
backupOnce(paths.pi, paths.piBak);
|
|
856
|
+
writeSecretFile(paths.pi, next);
|
|
857
|
+
console.log(t("Wrote {path}", { path: paths.pi }));
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case "agy": {
|
|
861
|
+
const nextAgy = transformConfig(paths.agy, (text) => wireAgySettings(text, { model: billingOnly ? undefined : model }));
|
|
862
|
+
backupOnce(paths.agy, paths.agyBak);
|
|
863
|
+
writeSecretFile(paths.agy, nextAgy);
|
|
864
|
+
wireShellTool(paths, "agy", {
|
|
865
|
+
bin: "agy",
|
|
866
|
+
envMap: {
|
|
867
|
+
GOOGLE_GEMINI_BASE_URL: ROOT,
|
|
868
|
+
GEMINI_API_KEY: key,
|
|
869
|
+
GOOGLE_API_KEY: key,
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
console.log(t("Wrote {path}", { path: paths.agy }));
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
default: {
|
|
876
|
+
const r = RECIPES[tool];
|
|
877
|
+
if (r) {
|
|
878
|
+
wireShellTool(paths, tool, { bin: r.bin, envMap: r.env(key, model) });
|
|
879
|
+
console.log(t("Wrote {path}", { path: paths.shellRc }));
|
|
880
|
+
}
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
state[tool] = {
|
|
886
|
+
wiredAt: new Date().toISOString(),
|
|
887
|
+
mode,
|
|
888
|
+
model,
|
|
889
|
+
models,
|
|
890
|
+
workspace_id: activeContext?.workspace_id,
|
|
891
|
+
workspace_name: activeContext?.workspace_name,
|
|
892
|
+
};
|
|
893
|
+
writeWiredState(paths, state);
|
|
300
894
|
}
|
|
301
895
|
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
896
|
+
export function revertToolWiring(tool, paths) {
|
|
897
|
+
const state = readWiredState(paths);
|
|
898
|
+
|
|
899
|
+
switch (tool) {
|
|
900
|
+
case "claude": {
|
|
901
|
+
if (existsSync(paths.claude)) {
|
|
902
|
+
writeSecretFile(paths.claude, transformConfig(paths.claude, unwireClaudeSettings));
|
|
903
|
+
console.log(t("Removed PleumRouter settings from {path}.", { path: paths.claude }));
|
|
904
|
+
}
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
case "codex": {
|
|
908
|
+
if (existsSync(paths.codex)) {
|
|
909
|
+
writeSecretFile(paths.codex, unwireCodexToml(readText(paths.codex) ?? ""));
|
|
910
|
+
console.log(t("Removed PleumRouter settings from {path}.", { path: paths.codex }));
|
|
911
|
+
}
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
case "opencode": {
|
|
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);
|
|
920
|
+
console.log(t("Removed PleumRouter settings from {path}.", { path: paths.opencode }));
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
case "aider": {
|
|
924
|
+
if (existsSync(paths.aider)) {
|
|
925
|
+
writeSecretFile(paths.aider, unwireAiderYaml(readText(paths.aider)));
|
|
926
|
+
console.log(t("Removed PleumRouter settings from {path}.", { path: paths.aider }));
|
|
927
|
+
}
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
case "crush": {
|
|
931
|
+
if (existsSync(paths.crush)) {
|
|
932
|
+
writeSecretFile(paths.crush, transformConfig(paths.crush, unwireCrushConfig));
|
|
933
|
+
console.log(t("Removed PleumRouter settings from {path}.", { path: paths.crush }));
|
|
934
|
+
}
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
case "pi": {
|
|
938
|
+
if (existsSync(paths.pi)) {
|
|
939
|
+
writeSecretFile(paths.pi, transformConfig(paths.pi, unwirePiConfig));
|
|
940
|
+
console.log(t("Removed PleumRouter settings from {path}.", { path: paths.pi }));
|
|
941
|
+
}
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
case "agy": {
|
|
945
|
+
if (existsSync(paths.agy)) {
|
|
946
|
+
writeSecretFile(paths.agy, transformConfig(paths.agy, unwireAgySettings));
|
|
947
|
+
}
|
|
948
|
+
unwireShellTool(paths, "agy");
|
|
949
|
+
console.log(t("Removed PleumRouter settings from {path}.", { path: paths.agy }));
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
default: {
|
|
953
|
+
const hadRc = shellRcTarget(paths.shellRc).present;
|
|
954
|
+
unwireShellTool(paths, tool);
|
|
955
|
+
if (hadRc) {
|
|
956
|
+
console.log(t("Removed PleumRouter settings from {path}.", { path: paths.shellRc }));
|
|
957
|
+
}
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
delete state[tool];
|
|
963
|
+
writeWiredState(paths, state);
|
|
306
964
|
}
|
|
307
965
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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();
|
|
312
970
|
}
|
|
313
971
|
|
|
314
|
-
export async function wire(args
|
|
315
|
-
|
|
972
|
+
export async function wire(args = [], {
|
|
973
|
+
auth = authFetch,
|
|
974
|
+
home = homedir(),
|
|
975
|
+
model,
|
|
976
|
+
models,
|
|
977
|
+
billingOnly = false,
|
|
978
|
+
activeContext,
|
|
979
|
+
} = {}) {
|
|
980
|
+
const parsed = parseWireArgs(args);
|
|
981
|
+
const tools = parsed.tools;
|
|
982
|
+
const isBilling = billingOnly || parsed.billingOnly;
|
|
983
|
+
const targetModel = model || parsed.model;
|
|
316
984
|
const paths = wirePaths(home);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
985
|
+
// 메뉴 경로는 호출자가 이미 고른 값을 넘긴다(중복 질문 방지).
|
|
986
|
+
const billing = activeContext ?? (await pickBillingContext(auth));
|
|
987
|
+
const record = await ensureWireKey({ auth, paths, activeContext: billing });
|
|
988
|
+
|
|
989
|
+
// 한 도구가 깨져도 나머지는 배선한다. 실패 목록은 마지막에 한 번에 던져 exit 1이 되게 한다.
|
|
990
|
+
const failures = [];
|
|
991
|
+
for (const tool of tools) {
|
|
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
|
+
}
|
|
1004
|
+
}
|
|
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."));
|
|
321
1008
|
printDesktopHints(record);
|
|
1009
|
+
if (failures.length) throw new Error(failures.join("\n"));
|
|
322
1010
|
}
|
|
323
1011
|
|
|
324
|
-
export async function unwire(args, { auth = authFetch, home = homedir() } = {}) {
|
|
1012
|
+
export async function unwire(args = [], { auth = authFetch, home = homedir() } = {}) {
|
|
325
1013
|
const { tools, revoke } = parseWireArgs(args);
|
|
326
1014
|
const paths = wirePaths(home);
|
|
327
|
-
|
|
328
|
-
|
|
1015
|
+
|
|
1016
|
+
const failures = [];
|
|
1017
|
+
for (const tool of tools) {
|
|
1018
|
+
try {
|
|
1019
|
+
revertToolWiring(tool, paths);
|
|
1020
|
+
} catch (e) {
|
|
1021
|
+
failures.push(e.message);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
// 실패가 있으면 키는 폐기하지 않는다(고친 뒤 다시 unwire할 수 있게).
|
|
1025
|
+
if (failures.length) throw new Error(failures.join("\n"));
|
|
1026
|
+
|
|
329
1027
|
if (!revoke) return;
|
|
330
1028
|
|
|
331
1029
|
const stored = (() => {
|
|
@@ -346,9 +1044,7 @@ export async function unwire(args, { auth = authFetch, home = homedir() } = {})
|
|
|
346
1044
|
if (existsSync(paths.wire)) {
|
|
347
1045
|
try {
|
|
348
1046
|
unlinkSync(paths.wire);
|
|
349
|
-
} catch {
|
|
350
|
-
// already gone
|
|
351
|
-
}
|
|
1047
|
+
} catch {}
|
|
352
1048
|
}
|
|
353
1049
|
console.log(t("Revoked desktop API key {id}.", { id: stored.id }));
|
|
354
1050
|
}
|