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/src/wire.mjs CHANGED
@@ -1,11 +1,46 @@
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
- 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
- export function wireClaudeSettings(existingJson, { root, key }) {
101
- let obj = {};
102
- if (existingJson && existingJson.trim()) {
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
- if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
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
- export function wireCodexToml(existingToml, { v1, key, model = "gpt-6.1-sol" }) {
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
- model = ${tomlBasicString(model)}
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
- const targetWorkspaceId =
183
- activeContext?.type === "workspace" ? activeContext.workspace_id : null;
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
- if (
199
- targetWorkspaceId &&
200
- String(found.workspace_id ?? "") !== String(targetWorkspaceId)
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: targetWorkspaceId }),
712
+ body: JSON.stringify({ target_workspace_id: target }),
206
713
  });
207
714
  if (moved.ok) {
208
- const movedRecord = { ...stored, workspace_id: targetWorkspaceId };
715
+ const movedRecord = withWorkspace(stored, shared);
209
716
  writeSecretFile(paths.wire, `${JSON.stringify(movedRecord, null, 2)}\n`);
210
717
  return movedRecord;
211
718
  }
212
- // 이동은 keys.manage_all 보유자(소유자/관리자)만 가능하다 — 일반 멤버는
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
- ...(targetWorkspaceId ? { workspace_id: targetWorkspaceId } : {}),
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
- ...(targetWorkspaceId ? { workspace_id: targetWorkspaceId } : {}),
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 tools = args.filter((arg) => arg !== "--revoke-key");
251
- if (tools.length === 0) return { tools: [...WIRE_TOOLS], revoke };
252
- const unknown = tools.find((tool) => !WIRE_TOOLS.includes(tool));
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
- return { tools: [...new Set(tools)], revoke };
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 applyClaude(paths, key) {
289
- backupOnce(paths.claude, paths.claudeBak);
290
- const next = wireClaudeSettings(readText(paths.claude), { root: ROOT, key });
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
- function applyCodex(paths, key) {
296
- backupOnce(paths.codex, paths.codexBak);
297
- const next = wireCodexToml(readText(paths.codex) ?? "", { v1: V1, key });
298
- writeSecretFile(paths.codex, next);
299
- console.log(t("Wrote {path}", { path: paths.codex }));
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 revertClaude(paths) {
303
- if (!existsSync(paths.claude)) return;
304
- writeSecretFile(paths.claude, unwireClaudeSettings(readText(paths.claude)));
305
- console.log(t("Removed PleumRouter settings from {path}.", { path: paths.claude }));
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
- function revertCodex(paths) {
309
- if (!existsSync(paths.codex)) return;
310
- writeSecretFile(paths.codex, unwireCodexToml(readText(paths.codex) ?? ""));
311
- console.log(t("Removed PleumRouter settings from {path}.", { path: paths.codex }));
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, { auth = authFetch, home = homedir() } = {}) {
315
- const { tools } = parseWireArgs(args);
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
- const record = await ensureWireKey({ auth, paths });
318
- if (tools.includes("claude")) applyClaude(paths, record.key);
319
- if (tools.includes("codex")) applyCodex(paths, record.key);
320
- for (const tool of tools) console.log(t("Wired {tool}.", { tool }));
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
- if (tools.includes("claude")) revertClaude(paths);
328
- if (tools.includes("codex")) revertCodex(paths);
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
  }