pleumrouter 0.5.44 → 0.5.45

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
@@ -2,10 +2,32 @@ import { homedir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { ROOT, V1, authFetch, getActiveContext } from "./config.mjs";
5
- import { tomlBasicString } from "./recipes.mjs";
5
+ import { tomlBasicString, RECIPES } from "./recipes.mjs";
6
6
  import { t } from "./i18n.mjs";
7
7
 
8
- export const WIRE_TOOLS = ["claude", "codex"];
8
+ export const WIRE_DEFAULT_TOOLS = ["claude", "codex"];
9
+ export const WIRE_TOOLS = [
10
+ "claude",
11
+ "codex",
12
+ "opencode",
13
+ "aider",
14
+ "crush",
15
+ "pi",
16
+ "agy",
17
+ "gemini",
18
+ "qwen",
19
+ "copilot",
20
+ "goose",
21
+ "openhands",
22
+ "omp",
23
+ "hermes",
24
+ "minimax",
25
+ "mimo",
26
+ "openclaw",
27
+ "plandex",
28
+ "interpreter",
29
+ ];
30
+
9
31
  export const WIRE_KEY_NAME = "pleum-desktop";
10
32
  export const WIRE_MARKER_START = "# pleum-wire-start";
11
33
  export const WIRE_MARKER_END = "# pleum-wire-end";
@@ -17,11 +39,35 @@ const CLAUDE_ENV_KEYS = [
17
39
  "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
18
40
  "API_TIMEOUT_MS",
19
41
  "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
42
+ "ANTHROPIC_CUSTOM_MODEL_OPTION",
43
+ "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME",
44
+ "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION",
45
+ "CLAUDE_CODE_SUBAGENT_MODEL",
46
+ "CLAUDE_CODE_SUBAGENT_MODEL_FORCE",
20
47
  ];
21
48
 
49
+ export const WIRE_WARNINGS = {
50
+ 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`.",
51
+ 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`.",
52
+ 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`.",
53
+ opencode: "Once wired, OpenCode (CLI, Desktop, IDE) will register PleumRouter in ~/.config/opencode/opencode.json and charge PleumRouter credits when Pleum models are used.",
54
+ default: "Once wired, running `{tool}` will route API calls through PleumRouter and deduct PleumRouter credits until you run `pleum unwire {tool}`.",
55
+ };
56
+
22
57
  export function wirePaths(home = homedir()) {
23
58
  const claude = join(home, ".claude", "settings.json");
24
59
  const codex = join(home, ".codex", "config.toml");
60
+ const opencode = join(home, ".config", "opencode", "opencode.json");
61
+ const opencodeAuth = join(home, ".local", "share", "opencode", "auth.json");
62
+ const aider = join(home, ".aider.conf.yml");
63
+ const crush = join(home, ".config", "crush", "crush.json");
64
+ const pi = join(home, ".pi", "agent", "models.json");
65
+ const agy = join(home, ".gemini", "antigravity-cli", "settings.json");
66
+ const shellRc = process.env.SHELL && process.env.SHELL.includes("bash")
67
+ ? join(home, ".bashrc")
68
+ : join(home, ".zshrc");
69
+ const state = join(home, ".pleum", "wired-agents.json");
70
+
25
71
  return {
26
72
  home,
27
73
  wire: join(home, ".pleum", "wire.json"),
@@ -29,6 +75,19 @@ export function wirePaths(home = homedir()) {
29
75
  claudeBak: `${claude}.pleum.bak`,
30
76
  codex,
31
77
  codexBak: `${codex}.pleum.bak`,
78
+ opencode,
79
+ opencodeBak: `${opencode}.pleum.bak`,
80
+ opencodeAuth,
81
+ aider,
82
+ aiderBak: `${aider}.pleum.bak`,
83
+ crush,
84
+ crushBak: `${crush}.pleum.bak`,
85
+ pi,
86
+ piBak: `${pi}.pleum.bak`,
87
+ agy,
88
+ agyBak: `${agy}.pleum.bak`,
89
+ shellRc,
90
+ state,
32
91
  };
33
92
  }
34
93
 
@@ -42,15 +101,13 @@ function readText(path) {
42
101
 
43
102
  export function writeSecretFile(path, contents, mode = 0o600) {
44
103
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
45
- // 기존 파일이 0644 등으로 있으면 writeFileSync의 mode는 무시된다(#41) —
46
- // tmp(강제 chmod) + rename 원자적 교체로 결과물이 항상 mode가 되게 한다.
47
104
  const tmp = `${path}.pleum-tmp-${process.pid}`;
48
105
  writeFileSync(tmp, contents, { mode });
49
106
  try {
50
107
  chmodSync(tmp, mode);
51
108
  renameSync(tmp, path);
52
109
  } catch (e) {
53
- try { unlinkSync(tmp); } catch {} // 키 평문 잔여를 남기지 않는다
110
+ try { unlinkSync(tmp); } catch {}
54
111
  throw e;
55
112
  }
56
113
  }
@@ -97,10 +154,13 @@ function removeRootValue(toml, name) {
97
154
  return root.replace(new RegExp(`^${name}\\s*=\\s*.+\\n?`, "m"), "") + rest;
98
155
  }
99
156
 
100
- export function wireClaudeSettings(existingJson, { root, key }) {
157
+ // ── Claude settings.json ──────────────────────────────────────────
158
+ export function wireClaudeSettings(existingJson, { root, key, model } = {}) {
101
159
  let obj = {};
102
160
  if (existingJson && existingJson.trim()) {
103
- obj = JSON.parse(existingJson);
161
+ try {
162
+ obj = JSON.parse(existingJson);
163
+ } catch {}
104
164
  if (obj === null || typeof obj !== "object" || Array.isArray(obj)) obj = {};
105
165
  }
106
166
  obj.env = {
@@ -111,12 +171,33 @@ export function wireClaudeSettings(existingJson, { root, key }) {
111
171
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
112
172
  };
113
173
  delete obj.env.ANTHROPIC_API_KEY;
174
+
175
+ if (model) {
176
+ const qualified = /^claude-/.test(model) ? `anthropic/${model}` : model;
177
+ obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION = qualified;
178
+ obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = `Pleum · ${model}`;
179
+ obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = "Selected in Pleum wire";
180
+ obj.env.CLAUDE_CODE_SUBAGENT_MODEL = "sonnet";
181
+ obj.env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE = "1";
182
+ obj.model = qualified;
183
+ } else {
184
+ delete obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
185
+ delete obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
186
+ delete obj.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
187
+ delete obj.env.CLAUDE_CODE_SUBAGENT_MODEL;
188
+ delete obj.env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE;
189
+ delete obj.model;
190
+ }
191
+
114
192
  return `${JSON.stringify(obj, null, 2)}\n`;
115
193
  }
116
194
 
117
195
  export function unwireClaudeSettings(existingJson) {
118
196
  if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
119
- const obj = JSON.parse(existingJson);
197
+ let obj = {};
198
+ try {
199
+ obj = JSON.parse(existingJson);
200
+ } catch {}
120
201
  if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
121
202
  return `${JSON.stringify({}, null, 2)}\n`;
122
203
  }
@@ -124,21 +205,28 @@ export function unwireClaudeSettings(existingJson) {
124
205
  for (const envKey of CLAUDE_ENV_KEYS) delete obj.env[envKey];
125
206
  if (Object.keys(obj.env).length === 0) delete obj.env;
126
207
  }
208
+ if (obj.model && typeof obj.model === "string" && (obj.model.startsWith("anthropic/") || obj.model.startsWith("pleum/"))) {
209
+ delete obj.model;
210
+ }
127
211
  return `${JSON.stringify(obj, null, 2)}\n`;
128
212
  }
129
213
 
130
- export function wireCodexToml(existingToml, { v1, key, model = "gpt-6.1-sol" }) {
214
+ // ── Codex config.toml ─────────────────────────────────────────────
215
+ export function wireCodexToml(existingToml, { v1, key, model = "gpt-6.1-sol", billingOnly = false } = {}) {
131
216
  const src = existingToml ?? "";
132
217
  const previousProvider = previousValue(src, "model_provider");
133
218
  const previousModel = previousValue(src, "model");
219
+ const targetModel = billingOnly && previousModel ? previousModel : model;
220
+
134
221
  let body = stripWireBlock(src);
135
222
  body = removeTomlTable(body, "model_providers.pleum.http_headers");
136
223
  body = removeTomlTable(body, "model_providers.pleum");
137
224
  body = removeRootValue(removeRootValue(body, "model_provider"), "model");
225
+
138
226
  const rootBlock = `${WIRE_MARKER_START}
139
227
  # previous_model_provider = ${previousProvider}
140
228
  # previous_model = ${previousModel}
141
- model = ${tomlBasicString(model)}
229
+ model = ${tomlBasicString(targetModel)}
142
230
  model_provider = "pleum"
143
231
  ${WIRE_MARKER_END}
144
232
  `;
@@ -170,6 +258,356 @@ export function unwireCodexToml(existingToml) {
170
258
  return `${restored ? `${restored}\n\n` : ""}${body.trim()}`.replace(/\n{3,}/g, "\n\n").trimStart() + "\n";
171
259
  }
172
260
 
261
+ // ── OpenCode (opencode.json & auth.json) ──────────────────────────
262
+ export function wireOpenCodeJson(existingJson, { v1, models = [], defaultModel } = {}) {
263
+ let obj = {};
264
+ if (existingJson && existingJson.trim()) {
265
+ try {
266
+ obj = JSON.parse(existingJson);
267
+ } catch {}
268
+ }
269
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
270
+
271
+ obj.provider = obj.provider || {};
272
+ const modelEntries = Object.fromEntries(models.map((id) => [id, {}]));
273
+ if (defaultModel) modelEntries[defaultModel] = {};
274
+
275
+ obj.provider.pleum = {
276
+ npm: "@ai-sdk/openai-compatible",
277
+ name: "PleumRouter",
278
+ options: {
279
+ baseURL: v1,
280
+ },
281
+ models: modelEntries,
282
+ };
283
+
284
+ if (defaultModel) {
285
+ if (obj.model && !obj.model.startsWith("pleum/")) {
286
+ obj._pleum_previous_model = obj.model;
287
+ }
288
+ obj.model = `pleum/${defaultModel}`;
289
+ }
290
+
291
+ return `${JSON.stringify(obj, null, 2)}\n`;
292
+ }
293
+
294
+ export function unwireOpenCodeJson(existingJson) {
295
+ if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
296
+ let obj = {};
297
+ try {
298
+ obj = JSON.parse(existingJson);
299
+ } catch {}
300
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
301
+
302
+ if (obj.provider && obj.provider.pleum) {
303
+ delete obj.provider.pleum;
304
+ if (Object.keys(obj.provider).length === 0) delete obj.provider;
305
+ }
306
+ if (obj.model && typeof obj.model === "string" && obj.model.startsWith("pleum/")) {
307
+ if (obj._pleum_previous_model) {
308
+ obj.model = obj._pleum_previous_model;
309
+ delete obj._pleum_previous_model;
310
+ } else {
311
+ delete obj.model;
312
+ }
313
+ }
314
+ return `${JSON.stringify(obj, null, 2)}\n`;
315
+ }
316
+
317
+ export function wireOpenCodeAuth(existingJson, key) {
318
+ let obj = {};
319
+ if (existingJson && existingJson.trim()) {
320
+ try {
321
+ obj = JSON.parse(existingJson);
322
+ } catch {}
323
+ }
324
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
325
+ obj.pleum = { type: "api", key };
326
+ return `${JSON.stringify(obj, null, 2)}\n`;
327
+ }
328
+
329
+ export function unwireOpenCodeAuth(existingJson) {
330
+ if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
331
+ let obj = {};
332
+ try {
333
+ obj = JSON.parse(existingJson);
334
+ } catch {}
335
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
336
+ delete obj.pleum;
337
+ return `${JSON.stringify(obj, null, 2)}\n`;
338
+ }
339
+
340
+ // ── Aider ~/.aider.conf.yml ───────────────────────────────────────
341
+ export function wireAiderYaml(existingYaml = "", { v1, key, model } = {}) {
342
+ const clean = (existingYaml || "").replace(
343
+ new RegExp(`${WIRE_MARKER_START}[\\s\\S]*?${WIRE_MARKER_END}\\n?`, "g"),
344
+ "",
345
+ ).trim();
346
+
347
+ const lines = [
348
+ WIRE_MARKER_START,
349
+ `openai-api-base: ${v1}`,
350
+ `openai-api-key: ${key}`,
351
+ ];
352
+ if (model) {
353
+ const cleanModel = model.replace(/^openai\//, "");
354
+ lines.push(`model: openai/${cleanModel}`);
355
+ }
356
+ lines.push(WIRE_MARKER_END);
357
+
358
+ return `${clean ? `${clean}\n\n` : ""}${lines.join("\n")}\n`;
359
+ }
360
+
361
+ export function unwireAiderYaml(existingYaml = "") {
362
+ return (existingYaml || "")
363
+ .replace(new RegExp(`${WIRE_MARKER_START}[\\s\\S]*?${WIRE_MARKER_END}\\n?`, "g"), "")
364
+ .trimStart();
365
+ }
366
+
367
+ // ── Crush ~/.config/crush/crush.json ──────────────────────────────
368
+ export function wireCrushConfig(existingJson, { v1, key, models = [] } = {}) {
369
+ let obj = {};
370
+ if (existingJson && existingJson.trim()) {
371
+ try {
372
+ obj = JSON.parse(existingJson);
373
+ } catch {}
374
+ }
375
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
376
+ obj.providers = obj.providers || {};
377
+ obj.providers.pleum = {
378
+ type: "openai-compat",
379
+ base_url: v1,
380
+ api_key: key,
381
+ models: models.map((id) => ({ id, name: id })),
382
+ };
383
+ return `${JSON.stringify(obj, null, 2)}\n`;
384
+ }
385
+
386
+ export function unwireCrushConfig(existingJson) {
387
+ if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
388
+ let obj = {};
389
+ try {
390
+ obj = JSON.parse(existingJson);
391
+ } catch {}
392
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
393
+ if (obj.providers && obj.providers.pleum) {
394
+ delete obj.providers.pleum;
395
+ if (Object.keys(obj.providers).length === 0) delete obj.providers;
396
+ }
397
+ return `${JSON.stringify(obj, null, 2)}\n`;
398
+ }
399
+
400
+ // ── Pi ~/.pi/agent/models.json ────────────────────────────────────
401
+ export function wirePiConfig(existingJson, { v1, key, models = [] } = {}) {
402
+ let obj = {};
403
+ if (existingJson && existingJson.trim()) {
404
+ try {
405
+ obj = JSON.parse(existingJson);
406
+ } catch {}
407
+ }
408
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
409
+ obj.providers = obj.providers || {};
410
+ obj.providers.pleum = {
411
+ baseUrl: v1,
412
+ api: "openai-completions",
413
+ apiKey: key,
414
+ models: models.map((id) => ({ id })),
415
+ };
416
+ return `${JSON.stringify(obj, null, 2)}\n`;
417
+ }
418
+
419
+ export function unwirePiConfig(existingJson) {
420
+ if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
421
+ let obj = {};
422
+ try {
423
+ obj = JSON.parse(existingJson);
424
+ } catch {}
425
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
426
+ if (obj.providers && obj.providers.pleum) {
427
+ delete obj.providers.pleum;
428
+ if (Object.keys(obj.providers).length === 0) delete obj.providers;
429
+ }
430
+ return `${JSON.stringify(obj, null, 2)}\n`;
431
+ }
432
+
433
+ // ── Antigravity ~/.gemini/antigravity-cli/settings.json ────────────
434
+ export function wireAgySettings(existingJson, { model } = {}) {
435
+ let obj = {};
436
+ if (existingJson && existingJson.trim()) {
437
+ try {
438
+ obj = JSON.parse(existingJson);
439
+ } catch {}
440
+ }
441
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
442
+ obj.modelProvider = "gemini";
443
+ if (model) obj.model = model;
444
+ return `${JSON.stringify(obj, null, 2)}\n`;
445
+ }
446
+
447
+ export function unwireAgySettings(existingJson) {
448
+ if (!existingJson || !existingJson.trim()) return `${JSON.stringify({}, null, 2)}\n`;
449
+ let obj = {};
450
+ try {
451
+ obj = JSON.parse(existingJson);
452
+ } catch {}
453
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `${JSON.stringify({}, null, 2)}\n`;
454
+ if (obj.modelProvider === "gemini") delete obj.modelProvider;
455
+ delete obj.model;
456
+ return `${JSON.stringify(obj, null, 2)}\n`;
457
+ }
458
+
459
+ // ── Shell function wrapper (rc files) ─────────────────────────────
460
+ export function wireShellWrapper(existingRc = "", { tool, bin, envMap = {} } = {}) {
461
+ const startMarker = `# pleum-wire:${tool}-start`;
462
+ const endMarker = `# pleum-wire:${tool}-end`;
463
+ const clean = (existingRc || "")
464
+ .replace(new RegExp(`${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g"), "")
465
+ .trim();
466
+
467
+ const envAssignments = Object.entries(envMap)
468
+ .map(([k, v]) => `${k}=${JSON.stringify(String(v))}`)
469
+ .join(" ");
470
+
471
+ const block = `${startMarker}
472
+ ${tool}() {
473
+ ${envAssignments} command ${bin || tool} "$@"
474
+ }
475
+ ${endMarker}`;
476
+
477
+ return `${clean ? `${clean}\n\n` : ""}${block}\n`;
478
+ }
479
+
480
+ export function unwireShellWrapper(existingRc = "", tool) {
481
+ const startMarker = `# pleum-wire:${tool}-start`;
482
+ const endMarker = `# pleum-wire:${tool}-end`;
483
+ return (existingRc || "")
484
+ .replace(new RegExp(`${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g"), "")
485
+ .trimStart();
486
+ }
487
+
488
+ // ── Wired State Metadata (~/.pleum/wired-agents.json) ─────────────
489
+ export function readWiredState(paths) {
490
+ try {
491
+ const raw = readText(paths.state);
492
+ return raw ? JSON.parse(raw) : {};
493
+ } catch {
494
+ return {};
495
+ }
496
+ }
497
+
498
+ export function writeWiredState(paths, state) {
499
+ writeSecretFile(paths.state, `${JSON.stringify(state, null, 2)}\n`);
500
+ }
501
+
502
+ // ── Wire Status Check ─────────────────────────────────────────────
503
+ export function getWireStatus(home = homedir()) {
504
+ const paths = wirePaths(home);
505
+ const state = readWiredState(paths);
506
+ const status = {};
507
+
508
+ // 1. Claude
509
+ const claudeText = readText(paths.claude);
510
+ const claudeWired = Boolean(claudeText && claudeText.includes("ANTHROPIC_BASE_URL"));
511
+ status.claude = {
512
+ tool: "claude",
513
+ label: "Claude Code",
514
+ wired: claudeWired,
515
+ mode: state.claude?.mode || (claudeWired ? "billing" : undefined),
516
+ model: state.claude?.model,
517
+ targetPath: paths.claude,
518
+ };
519
+
520
+ // 2. Codex
521
+ const codexText = readText(paths.codex);
522
+ const codexWired = Boolean(codexText && codexText.includes('model_provider = "pleum"'));
523
+ status.codex = {
524
+ tool: "codex",
525
+ label: "Codex CLI",
526
+ wired: codexWired,
527
+ mode: state.codex?.mode || (codexWired ? "billing" : undefined),
528
+ model: state.codex?.model,
529
+ targetPath: paths.codex,
530
+ };
531
+
532
+ // 3. OpenCode
533
+ const opencodeText = readText(paths.opencode);
534
+ const opencodeWired = Boolean(opencodeText && opencodeText.includes('"PleumRouter"'));
535
+ status.opencode = {
536
+ tool: "opencode",
537
+ label: "OpenCode",
538
+ wired: opencodeWired,
539
+ mode: state.opencode?.mode || (opencodeWired ? "list" : undefined),
540
+ model: state.opencode?.model,
541
+ targetPath: paths.opencode,
542
+ };
543
+
544
+ // 4. Aider
545
+ const aiderText = readText(paths.aider);
546
+ const aiderWired = Boolean(aiderText && aiderText.includes("openai-api-base") && aiderText.includes("apirouter.pleum.ai"));
547
+ status.aider = {
548
+ tool: "aider",
549
+ label: "Aider",
550
+ wired: aiderWired,
551
+ mode: state.aider?.mode || (aiderWired ? "billing" : undefined),
552
+ model: state.aider?.model,
553
+ targetPath: paths.aider,
554
+ };
555
+
556
+ // 5. Crush
557
+ const crushText = readText(paths.crush);
558
+ const crushWired = Boolean(crushText && crushText.includes("apirouter.pleum.ai"));
559
+ status.crush = {
560
+ tool: "crush",
561
+ label: "Crush",
562
+ wired: crushWired,
563
+ mode: state.crush?.mode || (crushWired ? "list" : undefined),
564
+ model: state.crush?.model,
565
+ targetPath: paths.crush,
566
+ };
567
+
568
+ // 6. Pi
569
+ const piText = readText(paths.pi);
570
+ const piWired = Boolean(piText && piText.includes("apirouter.pleum.ai"));
571
+ status.pi = {
572
+ tool: "pi",
573
+ label: "Pi",
574
+ wired: piWired,
575
+ mode: state.pi?.mode || (piWired ? "list" : undefined),
576
+ model: state.pi?.model,
577
+ targetPath: paths.pi,
578
+ };
579
+
580
+ // 7. Antigravity (agy)
581
+ const agyText = readText(paths.agy);
582
+ const rcText = readText(paths.shellRc) || "";
583
+ const agyWired = Boolean((agyText && agyText.includes('"modelProvider": "gemini"')) || rcText.includes("# pleum-wire:agy-start"));
584
+ status.agy = {
585
+ tool: "agy",
586
+ label: "Antigravity CLI",
587
+ wired: agyWired,
588
+ mode: state.agy?.mode || (agyWired ? "billing" : undefined),
589
+ model: state.agy?.model,
590
+ targetPath: paths.agy,
591
+ };
592
+
593
+ // Shell-based tools
594
+ for (const tool of WIRE_TOOLS) {
595
+ if (status[tool]) continue;
596
+ const r = RECIPES[tool];
597
+ const isWired = rcText.includes(`# pleum-wire:${tool}-start`);
598
+ status[tool] = {
599
+ tool,
600
+ label: r?.label || tool,
601
+ wired: isWired,
602
+ mode: state[tool]?.mode || (isWired ? "billing" : undefined),
603
+ model: state[tool]?.model,
604
+ targetPath: paths.shellRc,
605
+ };
606
+ }
607
+
608
+ return status;
609
+ }
610
+
173
611
  function parseJsonBody(res) {
174
612
  return res.json().catch(() => ({}));
175
613
  }
@@ -209,8 +647,6 @@ export async function ensureWireKey({
209
647
  writeSecretFile(paths.wire, `${JSON.stringify(movedRecord, null, 2)}\n`);
210
648
  return movedRecord;
211
649
  }
212
- // 이동은 keys.manage_all 보유자(소유자/관리자)만 가능하다 — 일반 멤버는
213
- // 아래 새 키 발급으로 공간 귀속 키를 만든다(과금 오귀속보다 키 재발급이 낫다).
214
650
  } else {
215
651
  return stored;
216
652
  }
@@ -245,15 +681,30 @@ export async function ensureWireKey({
245
681
  return record;
246
682
  }
247
683
 
248
- export function parseWireArgs(args) {
684
+ export function parseWireArgs(args = []) {
249
685
  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));
686
+ const billingOnly = args.includes("--billing-only");
687
+ let model;
688
+ const filtered = [];
689
+ for (let i = 0; i < args.length; i++) {
690
+ const a = args[i];
691
+ if (a === "--revoke-key" || a === "--billing-only") continue;
692
+ if (a === "--model" || a === "-m") {
693
+ model = args[++i];
694
+ continue;
695
+ }
696
+ filtered.push(a);
697
+ }
698
+
699
+ const unknown = filtered.find((tool) => !WIRE_TOOLS.includes(tool));
253
700
  if (unknown) {
254
701
  throw new Error(t("Unsupported tool: {tool}. Use claude or codex.", { tool: unknown }));
255
702
  }
256
- return { tools: [...new Set(tools)], revoke };
703
+
704
+ const res = { tools: filtered.length === 0 ? [...WIRE_DEFAULT_TOOLS] : [...new Set(filtered)], revoke };
705
+ if (billingOnly) res.billingOnly = true;
706
+ if (model) res.model = model;
707
+ return res;
257
708
  }
258
709
 
259
710
  export function wireHelp() {
@@ -285,47 +736,206 @@ function printDesktopHints(record) {
285
736
  console.log(t("kept the desktop key on this machine (logout does not delete it)"));
286
737
  }
287
738
 
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
- }
739
+ export function applyToolWiring(tool, paths, { key, model, models = [], billingOnly = false, activeContext } = {}) {
740
+ const state = readWiredState(paths);
741
+ const mode = billingOnly ? "billing" : (models.length > 0 ? "list" : (model ? "custom" : "billing"));
294
742
 
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 }));
300
- }
743
+ switch (tool) {
744
+ case "claude": {
745
+ backupOnce(paths.claude, paths.claudeBak);
746
+ const next = wireClaudeSettings(readText(paths.claude), { root: ROOT, key, model: billingOnly ? undefined : model });
747
+ writeSecretFile(paths.claude, next);
748
+ console.log(t("Wrote {path}", { path: paths.claude }));
749
+ break;
750
+ }
751
+ case "codex": {
752
+ backupOnce(paths.codex, paths.codexBak);
753
+ const next = wireCodexToml(readText(paths.codex) ?? "", { v1: V1, key, model: model || "gpt-6.1-sol", billingOnly });
754
+ writeSecretFile(paths.codex, next);
755
+ console.log(t("Wrote {path}", { path: paths.codex }));
756
+ break;
757
+ }
758
+ case "opencode": {
759
+ backupOnce(paths.opencode, paths.opencodeBak);
760
+ const nextJson = wireOpenCodeJson(readText(paths.opencode), { v1: V1, models, defaultModel: model });
761
+ writeSecretFile(paths.opencode, nextJson);
762
+ const nextAuth = wireOpenCodeAuth(readText(paths.opencodeAuth), key);
763
+ writeSecretFile(paths.opencodeAuth, nextAuth);
764
+ console.log(t("Wrote {path}", { path: paths.opencode }));
765
+ break;
766
+ }
767
+ case "aider": {
768
+ backupOnce(paths.aider, paths.aiderBak);
769
+ const next = wireAiderYaml(readText(paths.aider), { v1: V1, key, model: billingOnly ? undefined : model });
770
+ writeSecretFile(paths.aider, next);
771
+ console.log(t("Wrote {path}", { path: paths.aider }));
772
+ break;
773
+ }
774
+ case "crush": {
775
+ backupOnce(paths.crush, paths.crushBak);
776
+ const next = wireCrushConfig(readText(paths.crush), { v1: V1, key, models });
777
+ writeSecretFile(paths.crush, next);
778
+ console.log(t("Wrote {path}", { path: paths.crush }));
779
+ break;
780
+ }
781
+ case "pi": {
782
+ backupOnce(paths.pi, paths.piBak);
783
+ const next = wirePiConfig(readText(paths.pi), { v1: V1, key, models });
784
+ writeSecretFile(paths.pi, next);
785
+ console.log(t("Wrote {path}", { path: paths.pi }));
786
+ break;
787
+ }
788
+ case "agy": {
789
+ backupOnce(paths.agy, paths.agyBak);
790
+ const nextAgy = wireAgySettings(readText(paths.agy), { model: billingOnly ? undefined : model });
791
+ writeSecretFile(paths.agy, nextAgy);
792
+ const rcContent = readText(paths.shellRc) || "";
793
+ const nextRc = wireShellWrapper(rcContent, {
794
+ tool: "agy",
795
+ bin: "agy",
796
+ envMap: {
797
+ GOOGLE_GEMINI_BASE_URL: ROOT,
798
+ GEMINI_API_KEY: key,
799
+ GOOGLE_API_KEY: key,
800
+ },
801
+ });
802
+ writeSecretFile(paths.shellRc, nextRc, 0o644);
803
+ console.log(t("Wrote {path}", { path: paths.agy }));
804
+ break;
805
+ }
806
+ default: {
807
+ const r = RECIPES[tool];
808
+ if (r) {
809
+ const rcContent = readText(paths.shellRc) || "";
810
+ const envMap = r.env(key, model);
811
+ const nextRc = wireShellWrapper(rcContent, { tool, bin: r.bin, envMap });
812
+ writeSecretFile(paths.shellRc, nextRc, 0o644);
813
+ console.log(t("Wrote {path}", { path: paths.shellRc }));
814
+ }
815
+ break;
816
+ }
817
+ }
301
818
 
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 }));
819
+ state[tool] = {
820
+ wiredAt: new Date().toISOString(),
821
+ mode,
822
+ model,
823
+ models,
824
+ workspace_id: activeContext?.workspace_id,
825
+ workspace_name: activeContext?.workspace_name,
826
+ };
827
+ writeWiredState(paths, state);
306
828
  }
307
829
 
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 }));
830
+ export function revertToolWiring(tool, paths) {
831
+ const state = readWiredState(paths);
832
+
833
+ switch (tool) {
834
+ case "claude": {
835
+ if (existsSync(paths.claude)) {
836
+ writeSecretFile(paths.claude, unwireClaudeSettings(readText(paths.claude)));
837
+ console.log(t("Removed PleumRouter settings from {path}.", { path: paths.claude }));
838
+ }
839
+ break;
840
+ }
841
+ case "codex": {
842
+ if (existsSync(paths.codex)) {
843
+ writeSecretFile(paths.codex, unwireCodexToml(readText(paths.codex) ?? ""));
844
+ console.log(t("Removed PleumRouter settings from {path}.", { path: paths.codex }));
845
+ }
846
+ break;
847
+ }
848
+ case "opencode": {
849
+ if (existsSync(paths.opencode)) {
850
+ writeSecretFile(paths.opencode, unwireOpenCodeJson(readText(paths.opencode)));
851
+ }
852
+ if (existsSync(paths.opencodeAuth)) {
853
+ writeSecretFile(paths.opencodeAuth, unwireOpenCodeAuth(readText(paths.opencodeAuth)));
854
+ }
855
+ console.log(t("Removed PleumRouter settings from {path}.", { path: paths.opencode }));
856
+ break;
857
+ }
858
+ case "aider": {
859
+ if (existsSync(paths.aider)) {
860
+ writeSecretFile(paths.aider, unwireAiderYaml(readText(paths.aider)));
861
+ console.log(t("Removed PleumRouter settings from {path}.", { path: paths.aider }));
862
+ }
863
+ break;
864
+ }
865
+ case "crush": {
866
+ if (existsSync(paths.crush)) {
867
+ writeSecretFile(paths.crush, unwireCrushConfig(readText(paths.crush)));
868
+ console.log(t("Removed PleumRouter settings from {path}.", { path: paths.crush }));
869
+ }
870
+ break;
871
+ }
872
+ case "pi": {
873
+ if (existsSync(paths.pi)) {
874
+ writeSecretFile(paths.pi, unwirePiConfig(readText(paths.pi)));
875
+ console.log(t("Removed PleumRouter settings from {path}.", { path: paths.pi }));
876
+ }
877
+ break;
878
+ }
879
+ case "agy": {
880
+ if (existsSync(paths.agy)) {
881
+ writeSecretFile(paths.agy, unwireAgySettings(readText(paths.agy)));
882
+ }
883
+ if (existsSync(paths.shellRc)) {
884
+ writeSecretFile(paths.shellRc, unwireShellWrapper(readText(paths.shellRc), "agy"), 0o644);
885
+ }
886
+ console.log(t("Removed PleumRouter settings from {path}.", { path: paths.agy }));
887
+ break;
888
+ }
889
+ default: {
890
+ if (existsSync(paths.shellRc)) {
891
+ writeSecretFile(paths.shellRc, unwireShellWrapper(readText(paths.shellRc), tool), 0o644);
892
+ console.log(t("Removed PleumRouter settings from {path}.", { path: paths.shellRc }));
893
+ }
894
+ break;
895
+ }
896
+ }
897
+
898
+ delete state[tool];
899
+ writeWiredState(paths, state);
312
900
  }
313
901
 
314
- export async function wire(args, { auth = authFetch, home = homedir() } = {}) {
315
- const { tools } = parseWireArgs(args);
902
+ export async function wire(args = [], {
903
+ auth = authFetch,
904
+ home = homedir(),
905
+ model,
906
+ models,
907
+ billingOnly = false,
908
+ activeContext = getActiveContext(),
909
+ } = {}) {
910
+ const parsed = parseWireArgs(args);
911
+ const tools = parsed.tools;
912
+ const isBilling = billingOnly || parsed.billingOnly;
913
+ const targetModel = model || parsed.model;
316
914
  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 }));
915
+ const record = await ensureWireKey({ auth, paths, activeContext });
916
+
917
+ for (const tool of tools) {
918
+ applyToolWiring(tool, paths, {
919
+ key: record.key,
920
+ model: targetModel,
921
+ models,
922
+ billingOnly: isBilling,
923
+ activeContext,
924
+ });
925
+ console.log(t("Wired {tool}.", { tool }));
926
+ }
927
+
321
928
  printDesktopHints(record);
322
929
  }
323
930
 
324
- export async function unwire(args, { auth = authFetch, home = homedir() } = {}) {
931
+ export async function unwire(args = [], { auth = authFetch, home = homedir() } = {}) {
325
932
  const { tools, revoke } = parseWireArgs(args);
326
933
  const paths = wirePaths(home);
327
- if (tools.includes("claude")) revertClaude(paths);
328
- if (tools.includes("codex")) revertCodex(paths);
934
+
935
+ for (const tool of tools) {
936
+ revertToolWiring(tool, paths);
937
+ }
938
+
329
939
  if (!revoke) return;
330
940
 
331
941
  const stored = (() => {
@@ -346,9 +956,7 @@ export async function unwire(args, { auth = authFetch, home = homedir() } = {})
346
956
  if (existsSync(paths.wire)) {
347
957
  try {
348
958
  unlinkSync(paths.wire);
349
- } catch {
350
- // already gone
351
- }
959
+ } catch {}
352
960
  }
353
961
  console.log(t("Revoked desktop API key {id}.", { id: stored.id }));
354
962
  }