petbox-wire 0.1.0-ci.543

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.ts ADDED
@@ -0,0 +1,639 @@
1
+ // Bootstrap CLI for the global agent-wiring kit — shipped as the `petbox-wire` npm package
2
+ // (`npx petbox-wire <dir> <projectKey> …`), so a project can be wired without cloning the repo.
3
+ //
4
+ // npx petbox-wire <dir> <projectKey> [--env VAR] [--key KEY] [--workspace WS] [--cleanup-legacy]
5
+ // (dev, from a checkout: node <pkg>/src/wire.ts <dir> <projectKey> …)
6
+ //
7
+ // Idempotently wires a project to PetBox:
8
+ // 1. derive the env-var name for the API key
9
+ // 2. obtain the key (--key, else env var / ~/.petbox/keys.json) — minting keys is OUT OF SCOPE
10
+ // 3. validate the key against /api/auth/validate
11
+ // 4. persist the key everywhere agents look: ~/.petbox/keys.json (kit hooks) + user-scope
12
+ // env on Windows / ~/.petbox/env.sh sourced from login profiles on POSIX (the per-project
13
+ // MCP configs reference ${ENV_VAR}, so a real environment variable must exist)
14
+ // 5. copy the kit to a stable location (~/.petbox/wire/) so global hooks survive npx eviction
15
+ // 6. upsert the registry entry (prefix → project, envVar)
16
+ // 7. (re)generate per-project config files:
17
+ // - .mcp.json (Claude Code MCP)
18
+ // - .opencode/opencode.json (opencode MCP)
19
+ // - .factory/mcp.json (Factory Droid MCP — idempotent merge)
20
+ // - .claude/skills/petbox/SKILL.md (Claude Code skill; opencode reads it via its
21
+ // Claude-compatible skills discovery path)
22
+ // - .factory/skills/petbox/SKILL.md (Factory Droid skill)
23
+ // 8. install the global Claude + Droid hooks + opencode plugin (merge, never clobber live files);
24
+ // all links point at the stable copy (~/.petbox/wire/)
25
+ // 9. (--cleanup-legacy) remove the project's old per-project hook/plugin copies
26
+ // 10. self-smoke: POST a tiny session and assert the server applied it
27
+ //
28
+ // Unlike the hooks, this is a CLI: step failures surface loudly (no silent swallow).
29
+
30
+ import { execFileSync } from "node:child_process";
31
+ import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { dirname, join, resolve } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ const DEFAULT_BASE_URL = "https://petbox.3po.su";
37
+ // Where THIS run's kit lives (npx cache or a checkout's src dir).
38
+ const HERE = dirname(fileURLToPath(import.meta.url));
39
+ // Stable install location: the kit is copied here and every global hook/plugin link points at
40
+ // it, so wiring survives npx cache eviction and does not depend on any checkout.
41
+ const STABLE = join(homedir(), ".petbox", "wire");
42
+
43
+ // ---- arg parsing -----------------------------------------------------------
44
+
45
+ type Args = {
46
+ dir: string;
47
+ projectKey: string;
48
+ env?: string;
49
+ key?: string;
50
+ workspace?: string;
51
+ cleanupLegacy: boolean;
52
+ };
53
+
54
+ function usage(): never {
55
+ console.error(
56
+ "usage: npx petbox-wire <dir> <projectKey> [--env VAR] [--key KEY] [--workspace WS] [--cleanup-legacy]",
57
+ );
58
+ process.exit(2);
59
+ }
60
+
61
+ function parseArgs(argv: string[]): Args {
62
+ const positionals: string[] = [];
63
+ let env: string | undefined;
64
+ let key: string | undefined;
65
+ let workspace: string | undefined;
66
+ let cleanupLegacy = false;
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const a = argv[i];
69
+ if (a === "--env") env = argv[++i];
70
+ else if (a === "--key") key = argv[++i];
71
+ else if (a === "--workspace") workspace = argv[++i];
72
+ else if (a === "--cleanup-legacy") cleanupLegacy = true;
73
+ else if (a.startsWith("--")) {
74
+ console.error(`unknown flag: ${a}`);
75
+ usage();
76
+ } else positionals.push(a);
77
+ }
78
+ if (positionals.length < 2) usage();
79
+ return { dir: positionals[0], projectKey: positionals[1], env, key, workspace, cleanupLegacy };
80
+ }
81
+
82
+ // ---- small helpers ---------------------------------------------------------
83
+
84
+ const log = (msg: string) => console.log(msg);
85
+
86
+ function deriveEnvVar(projectKey: string): string {
87
+ return projectKey.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_API_KEY";
88
+ }
89
+
90
+ // Cross-platform key store (~/.petbox/keys.json): a flat JSON map { "<ENV_VAR>": "<key>" }.
91
+ // The kit's own hooks read it (via registry.ts) with no env var required. The per-project MCP
92
+ // configs still reference ${ENV_VAR}, so persistKeyForAgents() additionally materializes a real
93
+ // environment variable per platform.
94
+ function keysStorePath(): string {
95
+ return join(homedir(), ".petbox", "keys.json");
96
+ }
97
+
98
+ // Read a key from the store. Returns "" if the file/entry is missing.
99
+ function readKeyFromStore(name: string): string {
100
+ const store = readJson(keysStorePath());
101
+ const v = store && typeof store === "object" ? store[name] : undefined;
102
+ return typeof v === "string" ? v : "";
103
+ }
104
+
105
+ // Merge (never clobber) a key into the store. On POSIX tighten the file to 0600 (best-effort;
106
+ // skipped on Windows, where chmod is a no-op / can throw).
107
+ function writeKeyToStore(name: string, value: string): void {
108
+ const path = keysStorePath();
109
+ const store = readJson(path) ?? {};
110
+ store[name] = value;
111
+ writeJson(path, store);
112
+ if (process.platform !== "win32") {
113
+ try {
114
+ chmodSync(path, 0o600);
115
+ } catch {
116
+ /* best-effort */
117
+ }
118
+ }
119
+ }
120
+
121
+ // The agent MCP configs (.mcp.json `${VAR}`, opencode `{env:VAR}`, droid `${VAR}`) resolve the
122
+ // key from a REAL environment variable — keys.json alone only covers the kit hooks. Persist it:
123
+ // - Windows: user-scope env via PowerShell (visible to NEW terminals);
124
+ // - POSIX: regenerate ~/.petbox/env.sh from the whole key store and make sure the login
125
+ // profiles source it (marker-guarded, idempotent).
126
+ function persistKeyForAgents(envVar: string): void {
127
+ if (process.platform === "win32") {
128
+ const value = readKeyFromStore(envVar);
129
+ try {
130
+ execFileSync(
131
+ "powershell",
132
+ [
133
+ "-NoProfile",
134
+ "-NonInteractive",
135
+ "-Command",
136
+ `[Environment]::SetEnvironmentVariable('${envVar}', $env:WIRE_KEY_VALUE, 'User')`,
137
+ ],
138
+ { encoding: "utf8", env: { ...process.env, WIRE_KEY_VALUE: value } },
139
+ );
140
+ log(`[4/10] persisted ${envVar} to user-scope env (MCP configs read it; NEW terminals see it).`);
141
+ } catch (e) {
142
+ console.error(`[4/10] failed to persist ${envVar} to user-scope env — ${(e as Error).message}`);
143
+ process.exit(1);
144
+ }
145
+ return;
146
+ }
147
+
148
+ const store = readJson(keysStorePath()) ?? {};
149
+ const lines = Object.entries(store)
150
+ .filter(([, v]) => typeof v === "string")
151
+ .map(([k, v]) => `export ${k}=${JSON.stringify(v)}`);
152
+ const envShPath = join(homedir(), ".petbox", "env.sh");
153
+ writeFileSync(envShPath, "# Generated by petbox-wire from ~/.petbox/keys.json — do not edit.\n" + lines.join("\n") + "\n", "utf8");
154
+ try {
155
+ chmodSync(envShPath, 0o600);
156
+ } catch {
157
+ /* best-effort */
158
+ }
159
+
160
+ const marker = "# petbox-wire";
161
+ const sourceLine = `[ -f "$HOME/.petbox/env.sh" ] && . "$HOME/.petbox/env.sh" ${marker}`;
162
+ const profiles = [".profile", ".bashrc", ".zshenv"].map((f) => join(homedir(), f));
163
+ let sourced = false;
164
+ for (const p of profiles) {
165
+ if (!existsSync(p)) continue;
166
+ const content = readFileSync(p, "utf8");
167
+ if (!content.includes(marker)) appendFileSync(p, `\n${sourceLine}\n`, "utf8");
168
+ sourced = true;
169
+ }
170
+ if (!sourced) writeFileSync(profiles[0], sourceLine + "\n", "utf8");
171
+ log(`[4/10] wrote ${envShPath} and ensured login profiles source it (MCP configs read ${envVar}; new login shells see it).`);
172
+ }
173
+
174
+ function readJson(path: string): any {
175
+ try {
176
+ return JSON.parse(readFileSync(path, "utf8"));
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function writeJson(path: string, obj: unknown): void {
183
+ mkdirSync(dirname(path), { recursive: true });
184
+ writeFileSync(path, JSON.stringify(obj, null, 2) + "\n", "utf8");
185
+ }
186
+
187
+ function toFileUrl(absPath: string): string {
188
+ // Build a file:/// URL the way Node does (handles Windows drive letters / backslashes).
189
+ return new URL("file://" + (process.platform === "win32" ? "/" : "") + absPath.replace(/\\/g, "/")).href;
190
+ }
191
+
192
+ // ---- step 4: validate ------------------------------------------------------
193
+
194
+ async function validateKey(baseUrl: string, key: string, projectKey: string): Promise<void> {
195
+ const uri = `${baseUrl}/api/auth/validate`;
196
+ let resp: Response;
197
+ try {
198
+ resp = await fetch(uri, {
199
+ method: "GET",
200
+ headers: { "X-Api-Key": key },
201
+ signal: AbortSignal.timeout(12000),
202
+ });
203
+ } catch (e) {
204
+ console.error(`[3/10] validate: could not reach ${uri} — ${(e as Error).message}. Aborting.`);
205
+ process.exit(1);
206
+ }
207
+
208
+ if (resp.status === 401) {
209
+ console.error(`[3/10] validate: server rejected the API key (401). Aborting.`);
210
+ process.exit(1);
211
+ }
212
+ if (!resp.ok) {
213
+ // Non-standard / endpoint missing → warn and continue.
214
+ log(`[3/10] validate: unexpected status ${resp.status} (endpoint missing?); continuing with a warning.`);
215
+ return;
216
+ }
217
+ let body: any = null;
218
+ try {
219
+ body = await resp.json();
220
+ } catch {
221
+ log(`[3/10] validate: 200 but non-JSON body; continuing with a warning.`);
222
+ return;
223
+ }
224
+ // Contract (AuthApi.cs): 200 => { project, scopes } (camelCase, ASP.NET web defaults).
225
+ const proj = body?.project ?? body?.Project;
226
+ if (typeof proj === "string" && proj.length > 0) {
227
+ if (proj !== projectKey) {
228
+ console.error(
229
+ `[3/10] validate: key belongs to project '${proj}', not '${projectKey}'. Aborting.`,
230
+ );
231
+ process.exit(1);
232
+ }
233
+ log(`[3/10] validate: OK — key scoped to '${proj}'.`);
234
+ } else {
235
+ log(`[3/10] validate: 200 without a project field; continuing with a warning.`);
236
+ }
237
+ }
238
+
239
+ // ---- step 5: stable kit copy -----------------------------------------------
240
+
241
+ // Copy the running kit (HERE — an npx cache dir or a checkout's src/) into the stable location
242
+ // (~/.petbox/wire/), overwriting. Every global hook/plugin link is computed from STABLE, so the
243
+ // wiring keeps working after npx evicts its cache or a checkout moves. Copies the whole src dir
244
+ // (all .ts files + templates/SKILL.md). No-op when already running the installed copy.
245
+ function copyKitToStable(): void {
246
+ if (resolve(HERE) === resolve(STABLE)) {
247
+ log(`[5/10] stable copy: already running the installed kit at ${STABLE} — skipped.`);
248
+ return;
249
+ }
250
+ mkdirSync(STABLE, { recursive: true });
251
+ cpSync(HERE, STABLE, { recursive: true, force: true });
252
+ log(`[5/10] stable copy: kit installed to ${STABLE} (from ${HERE}).`);
253
+ }
254
+
255
+ // ---- step 6: registry ------------------------------------------------------
256
+
257
+ // Reuse the envVar of an existing registry entry for this exact prefix, so a plain re-run
258
+ // stays idempotent even when the var name was customized via --env in the past.
259
+ function registryEnvVar(prefix: string): string | undefined {
260
+ const data = readJson(join(homedir(), ".petbox", "projects.json"));
261
+ const entries: any[] = Array.isArray(data?.entries) ? data.entries : [];
262
+ const norm = (p: string) => p.replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
263
+ const hit = entries.find((e) => norm(String(e?.prefix ?? "")) === norm(prefix));
264
+ const v = hit?.envVar;
265
+ return typeof v === "string" && v.length > 0 ? v : undefined;
266
+ }
267
+
268
+ function upsertRegistry(prefix: string, project: string, envVar: string, baseUrl: string): void {
269
+ const path = join(homedir(), ".petbox", "projects.json");
270
+ const data = readJson(path) ?? {};
271
+ const entries: any[] = Array.isArray(data.entries) ? data.entries : [];
272
+ const norm = (p: string) => p.replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
273
+ const np = norm(prefix);
274
+ const next = entries.filter((e) => norm(String(e?.prefix ?? "")) !== np);
275
+ const entry: any = { prefix, project, envVar };
276
+ if (baseUrl !== DEFAULT_BASE_URL) entry.baseUrl = baseUrl;
277
+ next.push(entry);
278
+ writeJson(path, { entries: next });
279
+ log(`[6/10] registry: upserted ${prefix} → ${project} (${envVar}) in ${path}`);
280
+ }
281
+
282
+ // ---- step 7: per-project files --------------------------------------------
283
+
284
+ // Merge one MCP server into a possibly-shared JSON config (Droid's .factory/mcp.json can hold
285
+ // team servers), preserving every other server and top-level key. Idempotent: re-running with
286
+ // the same inputs yields byte-identical output. Only the `petbox` entry is (re)generated.
287
+ function mergeMcpServer(path: string, name: string, server: unknown): void {
288
+ const data = readJson(path) ?? {};
289
+ if (!data.mcpServers || typeof data.mcpServers !== "object") data.mcpServers = {};
290
+ data.mcpServers[name] = server;
291
+ writeJson(path, data);
292
+ }
293
+
294
+ // Skill surfaces wire.ts writes the rendered petbox SKILL.md into. opencode is intentionally
295
+ // absent: it discovers the skill through its Claude-compatible path (`.claude/skills/…`), and a
296
+ // second same-name copy under `.opencode/skills/` would be a duplicate whose resolution opencode
297
+ // does not document. Droid reads its own `.factory/skills/` root (its compat path is
298
+ // `.agent/skills/`, NOT `.claude/skills/`), so it needs a dedicated copy.
299
+ const SKILL_SURFACES: string[][] = [
300
+ [".claude", "skills"], // Claude Code (native) + opencode (Claude-compatible discovery)
301
+ [".factory", "skills"], // Factory Droid (native)
302
+ ];
303
+
304
+ function writeProjectFiles(dir: string, project: string, envVar: string, workspace: string): void {
305
+ // .mcp.json (Claude Code) — petbox-only file owned by wire.ts, regenerated whole.
306
+ const mcp = {
307
+ mcpServers: {
308
+ petbox: {
309
+ type: "http",
310
+ url: `${DEFAULT_BASE_URL}/mcp`,
311
+ headers: { "X-Api-Key": `\${${envVar}}` },
312
+ },
313
+ },
314
+ };
315
+ writeJson(join(dir, ".mcp.json"), mcp);
316
+ log(`[7/10] wrote ${join(dir, ".mcp.json")}`);
317
+
318
+ // .opencode/opencode.json (opencode) — petbox-only file owned by wire.ts, regenerated whole.
319
+ const oc = {
320
+ $schema: "https://opencode.ai/config.json",
321
+ mcp: {
322
+ petbox: {
323
+ type: "remote",
324
+ url: `${DEFAULT_BASE_URL}/mcp`,
325
+ enabled: true,
326
+ headers: { "X-Api-Key": `{env:${envVar}}` },
327
+ },
328
+ },
329
+ };
330
+ writeJson(join(dir, ".opencode", "opencode.json"), oc);
331
+ log(`[7/10] wrote ${join(dir, ".opencode", "opencode.json")}`);
332
+
333
+ // .factory/mcp.json (Factory Droid) — a project-level MCP config that may be shared with team
334
+ // servers, so merge (never clobber) rather than regenerate whole. Droid supports `${VAR}`
335
+ // env-var expansion in header values, so the key stays out of the file (no secret committed).
336
+ const droidMcpPath = join(dir, ".factory", "mcp.json");
337
+ mergeMcpServer(droidMcpPath, "petbox", {
338
+ type: "http",
339
+ url: `${DEFAULT_BASE_URL}/mcp`,
340
+ headers: { "X-Api-Key": `\${${envVar}}` },
341
+ disabled: false,
342
+ });
343
+ log(`[7/10] merged petbox MCP server into ${droidMcpPath}`);
344
+
345
+ // SKILL.md — render once from the template, then drop a copy into every native skill surface.
346
+ const tpl = readFileSync(join(HERE, "templates", "SKILL.md"), "utf8");
347
+ const skill = tpl
348
+ .replace(/\{\{PROJECT\}\}/g, project)
349
+ .replace(/\{\{WORKSPACE\}\}/g, workspace);
350
+ for (const surface of SKILL_SURFACES) {
351
+ const skillPath = join(dir, ...surface, "petbox", "SKILL.md");
352
+ mkdirSync(dirname(skillPath), { recursive: true });
353
+ writeFileSync(skillPath, skill, "utf8");
354
+ log(`[7/10] wrote ${skillPath}`);
355
+ }
356
+ }
357
+
358
+ // ---- step 8: global install ------------------------------------------------
359
+
360
+ // Hook commands are `node "<STABLE>/<file>.ts"`. Older wirings (this repo's own owner box
361
+ // included) left commands pointing at a checkout — e.g. `node "D:\…\agents\wiring\push-session.ts"`.
362
+ // Recognize a kit hook by these command suffixes so we can prune the stale ones (any that don't
363
+ // equal one of this run's stable commands).
364
+ const KIT_HOOK_SUFFIXES = [
365
+ 'push-session.ts"',
366
+ 'pull-memory.ts"',
367
+ 'droid-push-session.ts"',
368
+ 'droid-pull-memory.ts"',
369
+ ];
370
+
371
+ // Remove kit hook entries whose command is NOT one of the current stable commands (validCmds),
372
+ // then drop any now-empty groups. Mutates hooksObj in place; returns the count pruned.
373
+ function pruneStaleKitHooks(hooksObj: any, validCmds: Set<string>): number {
374
+ let removed = 0;
375
+ for (const event of Object.keys(hooksObj)) {
376
+ const groups: any[] = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
377
+ for (const g of groups) {
378
+ if (!g || !Array.isArray(g.hooks)) continue;
379
+ const before = g.hooks.length;
380
+ g.hooks = g.hooks.filter((h: any) => {
381
+ const c = typeof h?.command === "string" ? h.command : "";
382
+ const isKit = KIT_HOOK_SUFFIXES.some((s) => c.endsWith(s));
383
+ return !(isKit && !validCmds.has(c));
384
+ });
385
+ removed += before - g.hooks.length;
386
+ }
387
+ hooksObj[event] = groups.filter((g) => !(g && Array.isArray(g.hooks) && g.hooks.length === 0));
388
+ }
389
+ return removed;
390
+ }
391
+
392
+ function installGlobalHooks(): void {
393
+ const pushCmd = `node "${join(STABLE, "push-session.ts")}"`;
394
+ const pullCmd = `node "${join(STABLE, "pull-memory.ts")}"`;
395
+ const droidPushCmd = `node "${join(STABLE, "droid-push-session.ts")}"`;
396
+ const droidPullCmd = `node "${join(STABLE, "droid-pull-memory.ts")}"`;
397
+ // Every kit hook command this run considers current — the prune keeps these, drops the rest.
398
+ const validCmds = new Set([pushCmd, pullCmd, droidPushCmd, droidPullCmd]);
399
+
400
+ const settingsPath = join(homedir(), ".claude", "settings.json");
401
+ const settings = readJson(settingsPath) ?? {};
402
+ if (!settings.hooks || typeof settings.hooks !== "object") settings.hooks = {};
403
+ const prunedClaude = pruneStaleKitHooks(settings.hooks, validCmds);
404
+ if (prunedClaude > 0) log(`[8/10] pruned ${prunedClaude} stale claude kit hook(s) not pointing at ${STABLE}.`);
405
+
406
+ // Claude Code hooks shape: settings.hooks[event] = [{ matcher?, hooks: [{type, command}] }]
407
+ const ensureHook = (event: string, command: string) => {
408
+ const groups: any[] = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
409
+ const already = groups.some(
410
+ (g) => Array.isArray(g?.hooks) && g.hooks.some((h: any) => h?.command === command),
411
+ );
412
+ if (already) {
413
+ log(`[8/10] claude hook ${event} already present — skipped.`);
414
+ return;
415
+ }
416
+ groups.push({ hooks: [{ type: "command", command }] });
417
+ settings.hooks[event] = groups;
418
+ log(`[8/10] claude hook ${event} added.`);
419
+ };
420
+
421
+ ensureHook("Stop", pushCmd);
422
+ ensureHook("SessionStart", pullCmd);
423
+ writeJson(settingsPath, settings);
424
+ log(`[8/10] merged hooks into ${settingsPath}`);
425
+
426
+ // Factory Droid hooks: same JSON shape as Claude Code, merged into ~/.factory/settings.json
427
+ // under the `hooks` key (a documented fallback location). Droid exposes petbox tools as
428
+ // `mcp__petbox__*` and delivers Claude-Code-compatible snake_case payloads, so it reuses the
429
+ // shared protocol/append flow via its own thin hooks. No `enableHooks` flag is set: the droid
430
+ // hooks reference does not document one gating hook execution.
431
+ const droidSettingsPath = join(homedir(), ".factory", "settings.json");
432
+ const droidSettings = readJson(droidSettingsPath) ?? {};
433
+ if (!droidSettings.hooks || typeof droidSettings.hooks !== "object") droidSettings.hooks = {};
434
+ const prunedDroid = pruneStaleKitHooks(droidSettings.hooks, validCmds);
435
+ if (prunedDroid > 0) log(`[8/10] pruned ${prunedDroid} stale droid kit hook(s) not pointing at ${STABLE}.`);
436
+
437
+ const ensureDroidHook = (event: string, command: string) => {
438
+ const groups: any[] = Array.isArray(droidSettings.hooks[event]) ? droidSettings.hooks[event] : [];
439
+ const already = groups.some(
440
+ (g) => Array.isArray(g?.hooks) && g.hooks.some((h: any) => h?.command === command),
441
+ );
442
+ if (already) {
443
+ log(`[8/10] droid hook ${event} already present — skipped.`);
444
+ return;
445
+ }
446
+ groups.push({ hooks: [{ type: "command", command }] });
447
+ droidSettings.hooks[event] = groups;
448
+ log(`[8/10] droid hook ${event} added.`);
449
+ };
450
+
451
+ ensureDroidHook("Stop", droidPushCmd);
452
+ ensureDroidHook("SessionStart", droidPullCmd);
453
+ writeJson(droidSettingsPath, droidSettings);
454
+ log(`[8/10] merged droid hooks into ${droidSettingsPath}`);
455
+
456
+ // Global opencode plugin: thin shim re-exporting the kit plugin from the stable copy's file
457
+ // URL (overwritten each run, so an old shim pointing at a checkout is replaced).
458
+ const pluginAbs = join(STABLE, "opencode-plugin.ts");
459
+ const pluginUrl = toFileUrl(pluginAbs);
460
+ const shimDir = join(homedir(), ".config", "opencode", "plugins");
461
+ mkdirSync(shimDir, { recursive: true });
462
+ const shimPath = join(shimDir, "petbox.ts");
463
+ const shim = `// Auto-generated by wire.ts — global PetBox opencode plugin shim.
464
+ // Re-exports the kit plugin from its absolute path so a single source of truth serves
465
+ // every project (the active project is resolved from cwd via the shared registry).
466
+ export { PetboxPlugin, default } from "${pluginUrl}";
467
+ `;
468
+ writeFileSync(shimPath, shim, "utf8");
469
+ log(`[8/10] wrote global opencode plugin shim ${shimPath} → ${pluginUrl}`);
470
+ }
471
+
472
+ // ---- step 9: cleanup legacy ------------------------------------------------
473
+
474
+ function cleanupLegacy(dir: string): void {
475
+ // .claude/hooks/ — drop the whole per-project hooks folder.
476
+ const hooksDir = join(dir, ".claude", "hooks");
477
+ if (existsSync(hooksDir)) {
478
+ rmSync(hooksDir, { recursive: true, force: true });
479
+ log(`[9/10] removed ${hooksDir}`);
480
+ }
481
+
482
+ // .claude/settings.local.json — drop ONLY the hooks key, keep permissions etc.
483
+ const localPath = join(dir, ".claude", "settings.local.json");
484
+ const local = readJson(localPath);
485
+ if (local && typeof local === "object" && "hooks" in local) {
486
+ delete local.hooks;
487
+ writeJson(localPath, local);
488
+ log(`[9/10] removed 'hooks' key from ${localPath}`);
489
+ }
490
+
491
+ // .opencode/plugin/ — drop the per-project plugin folder.
492
+ const pluginDir = join(dir, ".opencode", "plugin");
493
+ if (existsSync(pluginDir)) {
494
+ rmSync(pluginDir, { recursive: true, force: true });
495
+ log(`[9/10] removed ${pluginDir}`);
496
+ }
497
+
498
+ // .opencode node deps — only if package.json depends solely on @opencode-ai/plugin.
499
+ const ocPkgPath = join(dir, ".opencode", "package.json");
500
+ const ocPkg = readJson(ocPkgPath);
501
+ if (ocPkg) {
502
+ const deps = { ...(ocPkg.dependencies ?? {}), ...(ocPkg.devDependencies ?? {}) };
503
+ const keys = Object.keys(deps);
504
+ const onlyPlugin = keys.length > 0 && keys.every((k) => k === "@opencode-ai/plugin");
505
+ const noDeps = keys.length === 0;
506
+ if (onlyPlugin || noDeps) {
507
+ for (const f of ["package.json", "bun.lock", "node_modules"]) {
508
+ const p = join(dir, ".opencode", f);
509
+ if (existsSync(p)) {
510
+ rmSync(p, { recursive: true, force: true });
511
+ log(`[9/10] removed ${p}`);
512
+ }
513
+ }
514
+ } else {
515
+ log(`[9/10] kept .opencode deps — package.json has non-plugin deps: ${keys.join(", ")}`);
516
+ }
517
+ }
518
+ }
519
+
520
+ // ---- step 10: self-smoke ---------------------------------------------------
521
+
522
+ async function selfSmoke(baseUrl: string, project: string, key: string): Promise<void> {
523
+ const uri = `${baseUrl}/api/sessions/${project}/wire-smoke?agent=wire`;
524
+ const body = JSON.stringify({ role: "user", content: "wire.ts self-smoke — verifying the session push pipeline." });
525
+ let resp: Response;
526
+ try {
527
+ resp = await fetch(uri, {
528
+ method: "POST",
529
+ headers: { "X-Api-Key": key, "Content-Type": "application/x-ndjson; charset=utf-8" },
530
+ body,
531
+ signal: AbortSignal.timeout(12000),
532
+ });
533
+ } catch (e) {
534
+ console.error(`[10/10] self-smoke: POST failed — ${(e as Error).message}`);
535
+ process.exitCode = 1;
536
+ return;
537
+ }
538
+ const text = await resp.text();
539
+ if (!resp.ok) {
540
+ console.error(`[10/10] self-smoke: HTTP ${resp.status} — ${text}`);
541
+ process.exitCode = 1;
542
+ return;
543
+ }
544
+ let parsed: any = null;
545
+ try {
546
+ parsed = JSON.parse(text);
547
+ } catch {
548
+ /* keep raw */
549
+ }
550
+ if (typeof parsed?.version === "number") {
551
+ log(`[10/10] self-smoke: OK — sessionId=${parsed.sessionId}, version=${parsed.version}, messages=${parsed.messageCount}`);
552
+ } else {
553
+ console.error(`[10/10] self-smoke: server did not return a numeric version — ${text}`);
554
+ process.exitCode = 1;
555
+ }
556
+ }
557
+
558
+ // ---- main ------------------------------------------------------------------
559
+
560
+ async function main(): Promise<void> {
561
+ const args = parseArgs(process.argv.slice(2));
562
+ const dir = resolve(args.dir);
563
+ const project = args.projectKey;
564
+ const baseUrl = DEFAULT_BASE_URL;
565
+ const workspace = args.workspace ?? "stdray";
566
+
567
+ if (!existsSync(dir)) {
568
+ console.error(`directory does not exist: ${dir}`);
569
+ process.exit(1);
570
+ }
571
+
572
+ // 1. env var — explicit --env wins; else reuse the existing registry entry (idempotent
573
+ // re-run with a customized var name); else derive from the project key.
574
+ const envVar = args.env ?? registryEnvVar(dir) ?? deriveEnvVar(project);
575
+ log(`[1/10] envVar = ${envVar}`);
576
+
577
+ // 2. key — --key wins, else process env (owner's inherited user-scope var still works),
578
+ // else the cross-platform key store (~/.petbox/keys.json).
579
+ let key = args.key;
580
+ if (key) {
581
+ log(`[2/10] using --key from the command line.`);
582
+ } else {
583
+ key = process.env[envVar] || readKeyFromStore(envVar) || "";
584
+ if (!key) {
585
+ console.error(
586
+ `[2/10] no API key found.\n` +
587
+ ` Provide one with --key <KEY>, or set ${envVar} (env or ~/.petbox/keys.json) first.\n` +
588
+ ` Mint a key from a Claude session on the $system project:\n` +
589
+ ` mcp__petbox__apikey_create (project='${project}')\n` +
590
+ ` Then re-run with --key <KEY>. (Minting keys is out of scope for wire.ts.)`,
591
+ );
592
+ process.exit(1);
593
+ }
594
+ log(`[2/10] using existing ${envVar} (env or key store).`);
595
+ }
596
+
597
+ // 3. validate — BEFORE persisting anything, so a bad key never lands in the stores.
598
+ await validateKey(baseUrl, key, project);
599
+
600
+ // 4. persist everywhere agents look: keys.json (kit hooks read it immediately) + a real
601
+ // env var per platform (the per-project MCP configs reference ${envVar}). Idempotent, so
602
+ // re-runs self-heal a machine where only one of the two exists.
603
+ writeKeyToStore(envVar, key);
604
+ log(`[4/10] persisted ${envVar} to ${keysStorePath()}.`);
605
+ persistKeyForAgents(envVar);
606
+
607
+ // 5. stable kit copy
608
+ copyKitToStable();
609
+
610
+ // 6. registry
611
+ upsertRegistry(dir, project, envVar, baseUrl);
612
+
613
+ // 7. project files
614
+ writeProjectFiles(dir, project, envVar, workspace);
615
+
616
+ // 8. global install
617
+ installGlobalHooks();
618
+
619
+ // 9. cleanup legacy
620
+ if (args.cleanupLegacy) cleanupLegacy(dir);
621
+ else log(`[9/10] cleanup-legacy not requested — skipped.`);
622
+
623
+ // 10. self-smoke
624
+ await selfSmoke(baseUrl, project, key);
625
+
626
+ if (process.env[envVar]) {
627
+ log(`done.`);
628
+ } else {
629
+ log(
630
+ `done. NOTE: start a NEW terminal${process.platform === "win32" ? "" : " (login shell)"} before launching agents — ` +
631
+ `their MCP configs read ${envVar} from the environment. The kit hooks work immediately (keys.json).`,
632
+ );
633
+ }
634
+ }
635
+
636
+ main().catch((e) => {
637
+ console.error(e?.stack ?? String(e));
638
+ process.exit(1);
639
+ });