petbox-wire 0.1.0-ci.1206

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.
Files changed (50) hide show
  1. package/README.md +119 -0
  2. package/bin/petbox-wire.js +30 -0
  3. package/package.json +44 -0
  4. package/src/agent-def-fetch.test.ts +317 -0
  5. package/src/agent-def-fetch.ts +409 -0
  6. package/src/agent-definition.ts +210 -0
  7. package/src/append-meta.test.ts +93 -0
  8. package/src/append.ts +172 -0
  9. package/src/apply-artifacts.ts +337 -0
  10. package/src/apply-root.test.ts +148 -0
  11. package/src/apply-root.ts +68 -0
  12. package/src/apply-write.test.ts +169 -0
  13. package/src/apply-write.ts +84 -0
  14. package/src/canon.ts +140 -0
  15. package/src/doctor-definition.test.ts +128 -0
  16. package/src/droid-pull-memory.ts +126 -0
  17. package/src/droid-push-session.ts +100 -0
  18. package/src/droid-transcript.ts +47 -0
  19. package/src/harness-capabilities.ts +126 -0
  20. package/src/harness-models.ts +158 -0
  21. package/src/hook-drain.ts +42 -0
  22. package/src/hook-prune.test.ts +165 -0
  23. package/src/hook-prune.ts +83 -0
  24. package/src/import-sessions.ts +261 -0
  25. package/src/opencode-plugin.ts +127 -0
  26. package/src/origin-marker.ts +37 -0
  27. package/src/posix-env.test.ts +99 -0
  28. package/src/posix-env.ts +61 -0
  29. package/src/protocol.test.ts +236 -0
  30. package/src/protocol.ts +136 -0
  31. package/src/pull-memory.test.ts +168 -0
  32. package/src/pull-memory.ts +119 -0
  33. package/src/push-session.ts +99 -0
  34. package/src/registry.ts +120 -0
  35. package/src/roles.test.ts +255 -0
  36. package/src/roles.ts +241 -0
  37. package/src/self-smoke.test.ts +103 -0
  38. package/src/self-smoke.ts +96 -0
  39. package/src/telemetry-settings.test.ts +65 -0
  40. package/src/telemetry-settings.ts +55 -0
  41. package/src/templates/SKILL.md +45 -0
  42. package/src/templates/agent-factory/SKILL.md +56 -0
  43. package/src/transcript.ts +86 -0
  44. package/src/truthfulness.test.ts +544 -0
  45. package/src/truthfulness.ts +164 -0
  46. package/src/wire-exit.test.ts +43 -0
  47. package/src/wire-exit.ts +25 -0
  48. package/src/wire-identity.test.ts +65 -0
  49. package/src/wire-identity.ts +64 -0
  50. package/src/wire.ts +1384 -0
package/src/wire.ts ADDED
@@ -0,0 +1,1384 @@
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
+ // [--telemetry] [--telemetry-log <name>]
6
+ // npx petbox-wire update
7
+ // (dev, from a checkout: node <pkg>/src/wire.ts <dir> <projectKey> …)
8
+ //
9
+ // `update` refreshes only the stable kit copy (~/.petbox/wire/) from this package — protocol,
10
+ // scripts, kit-owned templates — with the same mirror/orphan cleanup as a full wire. It does
11
+ // NOT touch keys, registry entries, the sticky telemetry flag, per-project MCP/skills, or require
12
+ // projectKey/key. It DOES run the prompt-RAG hook migration (below), because a refreshed kit no
13
+ // longer ships prompt-rag.ts and a leftover hook pointing at it would fail on every prompt.
14
+ //
15
+ // prompt-RAG (the opt-in UserPromptSubmit context injector) was REMOVED. Its kit files are gone and
16
+ // its flags no longer exist; what remains is a one-way MIGRATION that both `wire` and `update` run
17
+ // unconditionally and idempotently: prune any hook targeting prompt-rag.ts from ~/.claude/settings.json
18
+ // and ~/.factory/settings.json (see hook-prune.ts).
19
+ //
20
+ // --telemetry (opt-in, off by default) wires Claude Code to export its loop telemetry (OTLP
21
+ // metrics + log-events) into the project's petbox named log (default `cc-telemetry`): it ensures
22
+ // the log exists and merges the OTEL_* export env into the project's .claude/settings.json.
23
+ // CC-only — opencode/droid OTLP exporters can't carry the project/log path in the endpoint.
24
+ //
25
+ // Idempotently wires a project to PetBox:
26
+ // 1. derive the env-var name for the API key
27
+ // 2. obtain the key (--key, else env var / ~/.petbox/keys.json) — minting keys is OUT OF SCOPE
28
+ // 3. validate the key against /api/auth/validate
29
+ // 4. persist the key everywhere agents look: ~/.petbox/keys.json (kit hooks) + user-scope
30
+ // env on Windows / ~/.petbox/env.sh sourced from login profiles on POSIX (the per-project
31
+ // MCP configs reference ${ENV_VAR}, so a real environment variable must exist)
32
+ // 5. copy the kit to a stable location (~/.petbox/wire/) so global hooks survive npx eviction
33
+ // 6. upsert the registry entry (prefix → project, envVar)
34
+ // 7. (re)generate per-project config files:
35
+ // - .mcp.json (Claude Code MCP)
36
+ // - .opencode/opencode.json (opencode MCP)
37
+ // - .factory/mcp.json (Factory Droid MCP — idempotent merge)
38
+ // - .claude/skills/petbox/SKILL.md (Claude Code skill; opencode reads it via its
39
+ // Claude-compatible skills discovery path)
40
+ // - .factory/skills/petbox/SKILL.md (Factory Droid skill)
41
+ // - .claude/skills/petbox-agent-factory/SKILL.md (on-demand factory skill)
42
+ // - .factory/skills/petbox-agent-factory/SKILL.md
43
+ // 8. install the global Claude + Droid hooks + opencode plugin (merge, never clobber live files);
44
+ // all links point at the stable copy (~/.petbox/wire/), and any dead prompt-RAG hook left by
45
+ // an older kit is pruned
46
+ // 9. (--cleanup-legacy) remove the project's old per-project hook/plugin copies
47
+ // 10. self-smoke: POST a tiny session and assert the server applied it
48
+ //
49
+ // Unlike the hooks, this is a CLI: step failures surface loudly (no silent swallow).
50
+
51
+ import { execFileSync } from "node:child_process";
52
+ import { createHash } from "node:crypto";
53
+ import {
54
+ chmodSync,
55
+ cpSync,
56
+ existsSync,
57
+ mkdirSync,
58
+ readdirSync,
59
+ readFileSync,
60
+ rmSync,
61
+ statSync,
62
+ writeFileSync,
63
+ } from "node:fs";
64
+ import { homedir } from "node:os";
65
+ import { dirname, join, relative, resolve } from "node:path";
66
+ import { fileURLToPath } from "node:url";
67
+ import {
68
+ DEFAULT_DEFINITION_KEY,
69
+ resolveAgentDefinitionWithLkg,
70
+ } from "./agent-def-fetch.ts";
71
+ import { validateAgentDefinition, type AgentDefinition } from "./agent-definition.ts";
72
+ import { formatApplyBlocked, planApply } from "./apply-artifacts.ts";
73
+ import { resolveApplyRoot } from "./apply-root.ts";
74
+ import { cleanupLegacyArtifact, writeArtifact } from "./apply-write.ts";
75
+ import { HARNESS_IDS } from "./harness-capabilities.ts";
76
+ import { pruneDeadPromptRagHooks } from "./hook-prune.ts";
77
+ import { persistKeyForAgentsPosix } from "./posix-env.ts";
78
+ import { classifySelfSmokeResponse, finishWireRun } from "./self-smoke.ts";
79
+ import { classifyApplyExit, WIRE_EXIT } from "./wire-exit.ts";
80
+ import { deriveEnvVar, resolveWorkspace } from "./wire-identity.ts";
81
+ import { resolveProject } from "./registry.ts";
82
+ import {
83
+ exportRolesBootstrap,
84
+ formatResolvedBinding,
85
+ isEmptyRoles,
86
+ loadRoles,
87
+ resolveAgentRoles,
88
+ saveRoles,
89
+ useProfile,
90
+ } from "./roles.ts";
91
+ import { buildTelemetryOtlpEnv } from "./telemetry-settings.ts";
92
+ import { checkTruthfulness, formatViolations } from "./truthfulness.ts";
93
+
94
+ const DEFAULT_BASE_URL = "https://petbox.3po.su";
95
+ // Where THIS run's kit lives (npx cache or a checkout's src dir).
96
+ const HERE = dirname(fileURLToPath(import.meta.url));
97
+ // Stable install location: the kit is copied here and every global hook/plugin link points at
98
+ // it, so wiring survives npx cache eviction and does not depend on any checkout.
99
+ const STABLE = join(homedir(), ".petbox", "wire");
100
+
101
+ // ---- arg parsing -----------------------------------------------------------
102
+
103
+ type Args = {
104
+ dir: string;
105
+ projectKey: string;
106
+ env?: string;
107
+ key?: string;
108
+ workspace?: string;
109
+ cleanupLegacy: boolean;
110
+ telemetry: boolean;
111
+ telemetryLog: string;
112
+ };
113
+
114
+ const DEFAULT_TELEMETRY_LOG = "cc-telemetry";
115
+
116
+ // Print the usage banner and exit. `--help`/`-h` → stdout + exit 0; argument errors →
117
+ // stderr + exit WIRE_EXIT.usage (2). Same text either way.
118
+ function usage(exitCode: number = WIRE_EXIT.usage): never {
119
+ const text =
120
+ "usage: npx petbox-wire <dir> <projectKey> [--env VAR] [--key KEY] [--workspace WS] [--cleanup-legacy]\n" +
121
+ " [--telemetry] [--telemetry-log <name>]\n" +
122
+ " npx petbox-wire update\n" +
123
+ " npx petbox-wire apply [--definition <key>] [--offline]\n" +
124
+ " npx petbox-wire doctor\n" +
125
+ " npx petbox-wire roles\n" +
126
+ " npx petbox-wire roles export\n" +
127
+ " npx petbox-wire profile use <name>\n" +
128
+ " npx petbox-wire --help\n" +
129
+ "\n" +
130
+ "Wire a project to PetBox: global hooks, MCP configs and skills. (prompt-RAG was removed; wire and\n" +
131
+ "update now prune any leftover UserPromptSubmit hook that targets the retired prompt-rag.ts.)\n" +
132
+ "\n" +
133
+ "--env VAR Name of the environment variable holding the project's API key. Default for a fresh\n" +
134
+ " wire: PETBOX_<PROJECT>_API_KEY (same name the Connect page shows). An already-wired\n" +
135
+ " directory keeps the name recorded in ~/.petbox/projects.json.\n" +
136
+ "--workspace Override the workspace the server reports at GET /api/auth/validate (it fills\n" +
137
+ " WS {{WORKSPACE}} in the skill template). No hardcoded default: if the server reports\n" +
138
+ " none and the flag is absent, the wire fails with exit 2 (usage).\n" +
139
+ "\n" +
140
+ "update Refresh ~/.petbox/wire only (protocol/scripts/templates) from this package. Does not\n" +
141
+ " touch keys, registry, sticky telemetry, or per-project MCP/skills (it does prune the\n" +
142
+ " retired prompt-rag hook from the global settings files).\n" +
143
+ " Kit-copy only — does NOT compile per-harness agent artifacts (use apply).\n" +
144
+ "apply Compile per-harness startup artifacts from a portable agent definition + local\n" +
145
+ " role→model binding (~/.petbox/roles.json). Tries GET /api/{project}/agent-defs/{key}\n" +
146
+ " when cwd resolves via ~/.petbox/projects.json; on miss uses LKG cache\n" +
147
+ " (~/.petbox/cache/<project>.agent-def.json) with a staleness mark, else built-in\n" +
148
+ " DEFAULT only when no cache. --offline skips network (cache→DEFAULT). --definition\n" +
149
+ " <key> selects the server doc (default: default). Writes under the git worktree\n" +
150
+ " toplevel for cwd (`git rev-parse --show-toplevel`; falls back to cwd when cwd is not\n" +
151
+ " inside a git working tree) — NEVER the registry's project prefix, so apply run from a\n" +
152
+ " worktree targets that worktree, not the primary tree it was branched from. Always\n" +
153
+ " prints which root it resolved and how (git/cwd). Targets:\n" +
154
+ " claude-code .claude/agents/, opencode .opencode/agent/, droid .factory/droids/.\n" +
155
+ " Emitted names are namespaced petbox-<role> (frontmatter name: + file basename) —\n" +
156
+ " role.slug and ~/.petbox/roles.json stay unprefixed; only the render is. Every\n" +
157
+ " generated file carries a `petbox: managed` origin marker; apply REFUSES (loud,\n" +
158
+ " non-zero exit) to overwrite an existing file that lacks it — never clobbers a real\n" +
159
+ " user file. An owned pre-rename unprefixed leftover (e.g. worker.md) is removed once\n" +
160
+ " its petbox-<role>.md replacement is written; a same-named file without our marker is\n" +
161
+ " left alone.\n" +
162
+ " model: frontmatter only when bound (droid unbound → model: inherit) — never invents\n" +
163
+ " a concrete model id. Clean roles written; dirty skipped and reported.\n" +
164
+ " Exit codes: 0 full success; 1 hard failure (invalid definition/throw, OR a write was\n" +
165
+ " refused to avoid clobbering a non-PetBox file); 2 usage/args;\n" +
166
+ " 3 truthfulness partial/block (policy — distinct from usage).\n" +
167
+ "doctor Run the definition truthfulness gate for every known harness against the default\n" +
168
+ " definition (+ optional local binding is noted, not required). Prints OK or each\n" +
169
+ " violation. Exit 0 all OK; 1 hard fail (invalid default def); 2 usage; 3 truthfulness\n" +
170
+ " (same taxonomy as apply — policy block is not a hard crash). Offline.\n" +
171
+ "roles Print the local role→model binding for the active profile (~/.petbox/roles.json).\n" +
172
+ " Offline; empty store exits 0 with a clear message (never invents default models).\n" +
173
+ "roles export Write a bootstrap copy of roles.json to stdout (no secrets; pipe to a file on a\n" +
174
+ " new machine). Offline.\n" +
175
+ "profile use Set activeProfile in ~/.petbox/roles.json (creates an empty profile shell if missing).\n" +
176
+ " Offline. Re-run apply to rebuild artifacts after changing the active profile.";
177
+ (exitCode === 0 ? console.log : console.error)(text);
178
+ process.exit(exitCode);
179
+ }
180
+
181
+ function parseArgs(argv: string[]): Args {
182
+ const positionals: string[] = [];
183
+ let env: string | undefined;
184
+ let key: string | undefined;
185
+ let workspace: string | undefined;
186
+ let cleanupLegacy = false;
187
+ let telemetry = false;
188
+ let telemetryLog = DEFAULT_TELEMETRY_LOG;
189
+ for (let i = 0; i < argv.length; i++) {
190
+ const a = argv[i];
191
+ if (a === undefined) continue; // unreachable: i < argv.length is the loop condition
192
+ if (a === "--help" || a === "-h") usage(0);
193
+ else if (a === "--env") env = argv[++i];
194
+ else if (a === "--key") key = argv[++i];
195
+ else if (a === "--workspace") workspace = argv[++i];
196
+ else if (a === "--cleanup-legacy") cleanupLegacy = true;
197
+ else if (a === "--telemetry") telemetry = true;
198
+ // Missing value falls through to "" so the empty-log check below reports it as usage
199
+ // error, same as every other required-value flag here.
200
+ else if (a === "--telemetry-log") telemetryLog = argv[++i] ?? "";
201
+ else if (a.startsWith("--")) {
202
+ console.error(`unknown flag: ${a}`);
203
+ usage();
204
+ } else positionals.push(a);
205
+ }
206
+ if (!telemetryLog || !telemetryLog.trim()) {
207
+ console.error("--telemetry-log requires a non-empty log name");
208
+ usage();
209
+ }
210
+ const dir = positionals[0];
211
+ const projectKey = positionals[1];
212
+ if (dir === undefined || projectKey === undefined) {
213
+ console.error("usage: <dir> and <projectKey> are both required");
214
+ usage();
215
+ }
216
+ return {
217
+ dir,
218
+ projectKey,
219
+ ...(env !== undefined ? { env } : {}),
220
+ ...(key !== undefined ? { key } : {}),
221
+ ...(workspace !== undefined ? { workspace } : {}),
222
+ cleanupLegacy,
223
+ telemetry,
224
+ telemetryLog: telemetryLog.trim(),
225
+ };
226
+ }
227
+
228
+ // True when argv is the safe kit-refresh subcommand (no project/key required).
229
+ function isUpdateCommand(argv: string[]): boolean {
230
+ return argv[0] === "update";
231
+ }
232
+
233
+ function isDoctorCommand(argv: string[]): boolean {
234
+ return argv[0] === "doctor";
235
+ }
236
+
237
+ function isApplyCommand(argv: string[]): boolean {
238
+ return argv[0] === "apply";
239
+ }
240
+
241
+ // Local role/profile subcommands (offline; no project/key).
242
+ function isRolesCommand(argv: string[]): boolean {
243
+ return argv[0] === "roles";
244
+ }
245
+
246
+ function isProfileCommand(argv: string[]): boolean {
247
+ return argv[0] === "profile";
248
+ }
249
+
250
+ // doctor — truthfulness gate for each known harness vs the SAME definition apply would compile
251
+ // (doctor-gates-wrong-definition): server → LKG cache → built-in DEFAULT, exactly like apply
252
+ // (resolveApplyDefinition, shared with runApply below), not the hard-coded built-in default.
253
+ // Exit codes match apply (WIRE_EXIT): 0 OK; 1 hard (invalid def); 2 usage; 3 truthfulness policy.
254
+ async function runDoctor(argv: string[]): Promise<void> {
255
+ let offline = false;
256
+ for (let i = 1; i < argv.length; i++) {
257
+ const a = argv[i];
258
+ if (a === undefined) continue; // unreachable: i < argv.length is the loop condition
259
+ if (a === "--help" || a === "-h") usage(0);
260
+ else if (a === "--offline") offline = true;
261
+ else {
262
+ console.error(`doctor: unexpected argument: ${a}`);
263
+ usage(WIRE_EXIT.usage);
264
+ }
265
+ }
266
+
267
+ let definition: AgentDefinition;
268
+ try {
269
+ definition = await resolveApplyDefinition({
270
+ offline,
271
+ definitionKey: DEFAULT_DEFINITION_KEY,
272
+ cwd: process.cwd(),
273
+ label: "doctor",
274
+ });
275
+ validateAgentDefinition(definition);
276
+ } catch (e) {
277
+ console.error(`doctor: hard failure — ${e instanceof Error ? e.message : String(e)}`);
278
+ process.exit(WIRE_EXIT.hard);
279
+ }
280
+
281
+ const roles = loadRoles();
282
+ const bindingNote = isEmptyRoles(roles)
283
+ ? "local binding: (empty — capability gate only; no model ids to check)"
284
+ : `local binding: activeProfile=${roles.activeProfile} (model ids are gated against each harness)`;
285
+
286
+ log(`doctor: definition="${definition.name}" (${definition.roles.length} roles)`);
287
+ log(`doctor: ${bindingNote}`);
288
+
289
+ let hadTruthfulnessBlock = false;
290
+ for (const harness of HARNESS_IDS) {
291
+ // Same gate apply uses: capabilities + the LOCAL model binding for this harness, so a
292
+ // roles.json holding an id this harness cannot resolve fails here too (not at runtime).
293
+ const violations = checkTruthfulness(
294
+ definition,
295
+ harness,
296
+ resolveAgentRoles(roles, harness),
297
+ );
298
+ if (violations.length === 0) {
299
+ log(`doctor: ${harness} — OK`);
300
+ } else {
301
+ hadTruthfulnessBlock = true;
302
+ console.error(`doctor: ${harness} — ${violations.length} violation(s):`);
303
+ console.error(formatViolations(violations));
304
+ }
305
+ }
306
+
307
+ const code = classifyApplyExit({ hadTruthfulnessBlock });
308
+ if (code === WIRE_EXIT.ok) {
309
+ log("doctor: all known harnesses OK.");
310
+ process.exit(WIRE_EXIT.ok);
311
+ }
312
+ console.error(
313
+ `doctor: FAILED — a role requires a capability a harness does not declare, or is bound to a ` +
314
+ `model a harness cannot resolve (exit ${WIRE_EXIT.truthfulness}).`,
315
+ );
316
+ process.exit(WIRE_EXIT.truthfulness);
317
+ }
318
+
319
+ // apply — compile per-harness artifacts (distinct from update kit-copy).
320
+ // Definition source: server fetch when registry resolves cwd; else offline default.
321
+ //
322
+ // Per role × harness (definition-truthfulness + wiring-startup-symmetry):
323
+ // - dirty roles → skip + report (never silent); clean roles still written
324
+ // Exit codes (see WIRE_EXIT / classifyApplyExit — usage must stay distinct from truthfulness):
325
+ // 0 — full success: every known harness wrote all its roles, no skips
326
+ // 1 — hard failure: invalid definition / unexpected throw (NOT bad args)
327
+ // 2 — usage / bad arguments (via usage())
328
+ // 3 — truthfulness: policy blocked some roles/harnesses (partial write possible)
329
+ async function runApply(argv: string[]): Promise<void> {
330
+ let definitionKey = DEFAULT_DEFINITION_KEY;
331
+ let offline = false;
332
+ for (let i = 1; i < argv.length; i++) {
333
+ const a = argv[i];
334
+ if (a === undefined) continue; // unreachable: i < argv.length is the loop condition
335
+ if (a === "--help" || a === "-h") usage(0);
336
+ else if (a === "--offline") offline = true;
337
+ else if (a === "--definition") {
338
+ const v = argv[++i];
339
+ if (!v || v.startsWith("--")) {
340
+ console.error("apply: --definition requires a non-empty key");
341
+ usage(WIRE_EXIT.usage);
342
+ }
343
+ definitionKey = v.trim();
344
+ if (!definitionKey) {
345
+ console.error("apply: --definition requires a non-empty key");
346
+ usage(WIRE_EXIT.usage);
347
+ }
348
+ } else if (a.startsWith("--")) {
349
+ console.error(`apply: unexpected argument: ${a}`);
350
+ usage(WIRE_EXIT.usage);
351
+ } else {
352
+ console.error(`apply: unexpected argument: ${a}`);
353
+ usage(WIRE_EXIT.usage);
354
+ }
355
+ }
356
+
357
+ const { root, via } = resolveApplyRoot(process.cwd());
358
+ let definition;
359
+ try {
360
+ definition = await resolveApplyDefinition({
361
+ offline,
362
+ definitionKey,
363
+ cwd: process.cwd(),
364
+ });
365
+ validateAgentDefinition(definition);
366
+ } catch (e) {
367
+ console.error(`apply: hard failure — ${e instanceof Error ? e.message : String(e)}`);
368
+ process.exit(WIRE_EXIT.hard);
369
+ }
370
+
371
+ const rolesData = loadRoles();
372
+ log(`apply: root=${root} (via ${via})`);
373
+ log(`apply: definition="${definition.name}", harnesses=${HARNESS_IDS.join(",")}`);
374
+
375
+ let written = 0;
376
+ const writtenHarnesses: string[] = [];
377
+ const partialHarnesses: string[] = [];
378
+ const blockedHarnesses: string[] = [];
379
+ // Any writeArtifact refusal (bug: apply-clobbers-user-agent-files) — a real file that is not
380
+ // ours sat where we needed to write. Distinct from the truthfulness gate: it can happen even
381
+ // when every role is capability/model-clean, so it needs its own signal into the exit code.
382
+ let clobberBlocked = false;
383
+ const clobberedPaths: string[] = [];
384
+ for (const harness of HARNESS_IDS) {
385
+ const roleModels = resolveAgentRoles(rolesData, harness);
386
+ const plan = planApply(definition, harness, roleModels);
387
+
388
+ let writtenThisHarness = 0;
389
+ let clobberedThisHarness = false;
390
+ for (const file of plan.files) {
391
+ const abs = join(root, file.relativePath);
392
+ const outcome = writeArtifact(abs, file.content);
393
+ if (outcome.kind === "blocked") {
394
+ clobberBlocked = true;
395
+ clobberedThisHarness = true;
396
+ clobberedPaths.push(abs);
397
+ console.error(
398
+ `apply: REFUSED to overwrite ${abs} — it exists and does not carry the PetBox origin ` +
399
+ `marker (no \`petbox: managed\` in its frontmatter), so it is a real file, not one apply ` +
400
+ `wrote before. Nothing was touched. Move it aside (or rename the role) and re-run apply.`,
401
+ );
402
+ continue;
403
+ }
404
+ log(
405
+ `apply: wrote ${abs}` + (outcome.reason === "own" ? " (updated in place — ours)" : ""),
406
+ );
407
+ written++;
408
+ writtenThisHarness++;
409
+
410
+ // Namespacing rename cleanup: remove an OWNED pre-rename unprefixed leftover now that its
411
+ // petbox-<role> replacement exists. Only after a successful write — never orphan a role by
412
+ // deleting the old file when the new one could not be written. Never touches a path that
413
+ // lacks our marker (cleanupLegacyArtifact's own contract — see apply-write.ts).
414
+ if (file.legacyRelativePath !== file.relativePath) {
415
+ const legacyAbs = join(root, file.legacyRelativePath);
416
+ const legacyOutcome = cleanupLegacyArtifact(legacyAbs);
417
+ if (legacyOutcome === "removed") {
418
+ log(`apply: removed legacy unprefixed ${legacyAbs} (ours, superseded by ${abs})`);
419
+ } else if (legacyOutcome === "kept-foreign") {
420
+ log(
421
+ `apply: left ${legacyAbs} in place — not ours (no PetBox origin marker); not renamed or deleted.`,
422
+ );
423
+ }
424
+ }
425
+ }
426
+
427
+ for (const w of plan.warnings) {
428
+ console.error(`apply: warn — ${w}`);
429
+ }
430
+
431
+ if (plan.violations.length > 0 || clobberedThisHarness) {
432
+ if (plan.violations.length > 0) {
433
+ console.error(formatApplyBlocked(plan.violations, plan.harness, plan.skippedRoles));
434
+ }
435
+ if (writtenThisHarness > 0) partialHarnesses.push(plan.harness);
436
+ else blockedHarnesses.push(plan.harness);
437
+ } else if (writtenThisHarness > 0) {
438
+ writtenHarnesses.push(plan.harness);
439
+ }
440
+ }
441
+
442
+ // Structured summary (machine-readable-ish one line + human detail above).
443
+ const summary = {
444
+ writtenFiles: written,
445
+ writtenHarnesses,
446
+ partialHarnesses,
447
+ blockedHarnesses,
448
+ clobberBlockedPaths: clobberedPaths,
449
+ };
450
+ log(
451
+ `apply: result written=${written} ` +
452
+ `ok=[${writtenHarnesses.join(",")}] ` +
453
+ `partial=[${partialHarnesses.join(",")}] ` +
454
+ `blocked=[${blockedHarnesses.join(",")}]` +
455
+ (clobberedPaths.length > 0 ? ` clobber-refused=[${clobberedPaths.join(",")}]` : ""),
456
+ );
457
+
458
+ const hadTruthfulnessBlock = partialHarnesses.length > 0 || blockedHarnesses.length > 0;
459
+ const code = classifyApplyExit({ hardError: clobberBlocked, hadTruthfulnessBlock });
460
+ if (code === WIRE_EXIT.ok) {
461
+ log("apply: done — all known harnesses accepted every role.");
462
+ process.exit(WIRE_EXIT.ok);
463
+ }
464
+ if (clobberBlocked) {
465
+ console.error(
466
+ `apply: hard failure — refused to overwrite ${clobberedPaths.length} non-PetBox file(s) ` +
467
+ `(exit ${WIRE_EXIT.hard}). ${JSON.stringify(summary)}`,
468
+ );
469
+ process.exit(WIRE_EXIT.hard);
470
+ }
471
+ console.error(
472
+ `apply: truthfulness partial — some roles/harnesses blocked (exit ${WIRE_EXIT.truthfulness}). ${JSON.stringify(summary)}`,
473
+ );
474
+ process.exit(WIRE_EXIT.truthfulness);
475
+ }
476
+
477
+ // Server → LKG cache → built-in DEFAULT (definition-offline-lkg).
478
+ // Server is authoritative; disk is LKG replica. roles.json polarity is separate (not here).
479
+ // `label` prefixes the log lines: apply and doctor share this resolution so that doctor gates
480
+ // the definition apply would actually compile, and each says so under its own name.
481
+ async function resolveApplyDefinition(opts: {
482
+ offline: boolean;
483
+ definitionKey: string;
484
+ cwd: string;
485
+ label?: string;
486
+ }): Promise<AgentDefinition> {
487
+ const label = opts.label ?? "apply";
488
+ const resolved = resolveProject(opts.cwd);
489
+ const got = await resolveAgentDefinitionWithLkg({
490
+ offline: opts.offline,
491
+ definitionKey: opts.definitionKey,
492
+ ...(resolved?.project !== undefined ? { projectKey: resolved.project } : {}),
493
+ ...(resolved?.baseUrl !== undefined ? { baseUrl: resolved.baseUrl } : {}),
494
+ ...(resolved?.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
495
+ });
496
+
497
+ if (got.source === "server") {
498
+ log(`${label}: using server definition ${got.key} v${got.version}`);
499
+ } else if (got.source === "lkg") {
500
+ log(`${label}: ${got.staleMarker ?? "using LKG agent definition cache"}`);
501
+ log(`${label}: using LKG definition ${got.key} v${got.version} (stale)`);
502
+ } else {
503
+ log(`${label}: offline default definition (no server, no LKG cache)`);
504
+ }
505
+ return got.definition;
506
+ }
507
+
508
+ // Print active profile + agent/role/model tree from ~/.petbox/roles.json. Exit 0 when empty.
509
+ function runRoles(argv: string[]): void {
510
+ // roles | roles export (+ optional --help)
511
+ const sub = argv[1];
512
+ if (sub === "--help" || sub === "-h") usage(0);
513
+ if (sub === "export") {
514
+ for (let i = 2; i < argv.length; i++) {
515
+ const a = argv[i];
516
+ if (a === undefined) continue; // unreachable: i < argv.length is the loop condition
517
+ if (a === "--help" || a === "-h") usage(0);
518
+ console.error(`roles export: unexpected argument: ${a}`);
519
+ usage();
520
+ }
521
+ const data = loadRoles();
522
+ // stdout only — bootstrap for a new machine (document in usage).
523
+ console.log(JSON.stringify(exportRolesBootstrap(data), null, 2));
524
+ return;
525
+ }
526
+ if (sub !== undefined) {
527
+ console.error(`roles: unexpected argument: ${sub}`);
528
+ usage();
529
+ }
530
+ const data = loadRoles();
531
+ if (isEmptyRoles(data) && !data.profiles[data.activeProfile]) {
532
+ log(
533
+ `roles: no bindings in ${join(homedir(), ".petbox", "roles.json")} (activeProfile would be "default").\n` +
534
+ ` Bindings are local — set models in that file or via a future apply path; nothing is invented.`,
535
+ );
536
+ return;
537
+ }
538
+ log(formatResolvedBinding(data));
539
+ }
540
+
541
+ // profile use <name> — set activeProfile; create empty shell if missing.
542
+ function runProfile(argv: string[]): void {
543
+ const sub = argv[1];
544
+ if (sub === "--help" || sub === "-h") usage(0);
545
+ if (sub !== "use") {
546
+ console.error(`profile: expected "use <name>"${sub ? `, got "${sub}"` : ""}`);
547
+ usage();
548
+ }
549
+ const name = argv[2];
550
+ if (!name || name.startsWith("-")) {
551
+ console.error("profile use: requires a non-empty <name>");
552
+ usage();
553
+ }
554
+ for (let i = 3; i < argv.length; i++) {
555
+ const a = argv[i];
556
+ if (a === undefined) continue; // unreachable: i < argv.length is the loop condition
557
+ if (a === "--help" || a === "-h") usage(0);
558
+ console.error(`profile use: unexpected argument: ${a}`);
559
+ usage();
560
+ }
561
+ const before = loadRoles();
562
+ const created = !before.profiles[name];
563
+ const next = useProfile(before, name);
564
+ saveRoles(next);
565
+ log(
566
+ `profile: activeProfile = "${next.activeProfile}"` +
567
+ (created ? " (created empty profile shell)" : "") +
568
+ `\n wrote ${join(homedir(), ".petbox", "roles.json")}` +
569
+ `\n re-run apply to rebuild artifacts (profile use does not compile).`,
570
+ );
571
+ }
572
+
573
+ // ---- small helpers ---------------------------------------------------------
574
+
575
+ const log = (msg: string) => console.log(msg);
576
+
577
+ // deriveEnvVar / resolveWorkspace live in wire-identity.ts (importable by unit tests; wire.ts
578
+ // itself runs main() on import and cannot be imported).
579
+
580
+ // Cross-platform key store (~/.petbox/keys.json): a flat JSON map { "<ENV_VAR>": "<key>" }.
581
+ // The kit's own hooks read it (via registry.ts) with no env var required. The per-project MCP
582
+ // configs still reference ${ENV_VAR}, so persistKeyForAgents() additionally materializes a real
583
+ // environment variable per platform.
584
+ function keysStorePath(): string {
585
+ return join(homedir(), ".petbox", "keys.json");
586
+ }
587
+
588
+ // Read a key from the store. Returns "" if the file/entry is missing.
589
+ function readKeyFromStore(name: string): string {
590
+ const store = readJson(keysStorePath());
591
+ const v = store && typeof store === "object" ? store[name] : undefined;
592
+ return typeof v === "string" ? v : "";
593
+ }
594
+
595
+ // Merge (never clobber) a key into the store. On POSIX tighten the file to 0600 (best-effort;
596
+ // skipped on Windows, where chmod is a no-op / can throw).
597
+ function writeKeyToStore(name: string, value: string): void {
598
+ const path = keysStorePath();
599
+ const store = readJson(path) ?? {};
600
+ store[name] = value;
601
+ writeJson(path, store);
602
+ if (process.platform !== "win32") {
603
+ try {
604
+ chmodSync(path, 0o600);
605
+ } catch {
606
+ /* best-effort */
607
+ }
608
+ }
609
+ }
610
+
611
+ // The agent MCP configs (.mcp.json `${VAR}`, opencode `{env:VAR}`, droid `${VAR}`) resolve the
612
+ // key from a REAL environment variable — keys.json alone only covers the kit hooks. Persist it:
613
+ // - Windows: user-scope env via PowerShell (visible to NEW terminals);
614
+ // - POSIX: regenerate ~/.petbox/env.sh from the whole key store and make sure the login
615
+ // profiles source it (marker-guarded, idempotent).
616
+ function persistKeyForAgents(envVar: string): void {
617
+ if (process.platform === "win32") {
618
+ const value = readKeyFromStore(envVar);
619
+ try {
620
+ execFileSync(
621
+ "powershell",
622
+ [
623
+ "-NoProfile",
624
+ "-NonInteractive",
625
+ "-Command",
626
+ `[Environment]::SetEnvironmentVariable('${envVar}', $env:WIRE_KEY_VALUE, 'User')`,
627
+ ],
628
+ { encoding: "utf8", env: { ...process.env, WIRE_KEY_VALUE: value } },
629
+ );
630
+ log(`[4/10] persisted ${envVar} to user-scope env (MCP configs read it; NEW terminals see it).`);
631
+ } catch (e) {
632
+ console.error(`[4/10] failed to persist ${envVar} to user-scope env — ${(e as Error).message}`);
633
+ process.exit(1);
634
+ }
635
+ return;
636
+ }
637
+
638
+ // The actual file-writing logic lives in posix-env.ts — a side-effect-free module (no
639
+ // top-level main()) so it stays importable by tests, unlike wire.ts itself.
640
+ const envShPath = persistKeyForAgentsPosix(homedir());
641
+ log(`[4/10] wrote ${envShPath} and ensured login profiles source it (MCP configs read ${envVar}; new login shells see it).`);
642
+ }
643
+
644
+ function readJson(path: string): any {
645
+ try {
646
+ return JSON.parse(readFileSync(path, "utf8"));
647
+ } catch {
648
+ return null;
649
+ }
650
+ }
651
+
652
+ function writeJson(path: string, obj: unknown): void {
653
+ mkdirSync(dirname(path), { recursive: true });
654
+ writeFileSync(path, JSON.stringify(obj, null, 2) + "\n", "utf8");
655
+ }
656
+
657
+ function toFileUrl(absPath: string): string {
658
+ // Build a file:/// URL the way Node does (handles Windows drive letters / backslashes).
659
+ return new URL("file://" + (process.platform === "win32" ? "/" : "") + absPath.replace(/\\/g, "/")).href;
660
+ }
661
+
662
+ // ---- step 4: validate ------------------------------------------------------
663
+
664
+ // What GET /api/auth/validate reports back (AuthValidResponse). `workspace` is only present on
665
+ // servers new enough to report it — absent on an older deployment, which is a supported case
666
+ // (the caller then requires --workspace instead of inventing a default).
667
+ type ValidatedKey = {
668
+ project?: string;
669
+ scopes?: unknown;
670
+ workspace?: string;
671
+ };
672
+
673
+ // Validate the key and RETURN what the server said about it (null when the server could not be
674
+ // asked meaningfully: endpoint missing / non-JSON body). Hard-exits on a rejected key or a
675
+ // project mismatch, so nothing is persisted for a bad key.
676
+ async function validateKey(
677
+ baseUrl: string,
678
+ key: string,
679
+ projectKey: string,
680
+ ): Promise<ValidatedKey | null> {
681
+ const uri = `${baseUrl}/api/auth/validate`;
682
+ let resp: Response;
683
+ try {
684
+ resp = await fetch(uri, {
685
+ method: "GET",
686
+ headers: { "X-Api-Key": key },
687
+ signal: AbortSignal.timeout(12000),
688
+ });
689
+ } catch (e) {
690
+ console.error(`[3/10] validate: could not reach ${uri} — ${(e as Error).message}. Aborting.`);
691
+ process.exit(1);
692
+ }
693
+
694
+ if (resp.status === 401) {
695
+ console.error(`[3/10] validate: server rejected the API key (401). Aborting.`);
696
+ process.exit(1);
697
+ }
698
+ if (!resp.ok) {
699
+ // Non-standard / endpoint missing → warn and continue.
700
+ log(`[3/10] validate: unexpected status ${resp.status} (endpoint missing?); continuing with a warning.`);
701
+ return null;
702
+ }
703
+ let body: any = null;
704
+ try {
705
+ body = await resp.json();
706
+ } catch {
707
+ log(`[3/10] validate: 200 but non-JSON body; continuing with a warning.`);
708
+ return null;
709
+ }
710
+ // Contract (AuthApi.cs): 200 => { project, scopes, workspace } (camelCase, ASP.NET web
711
+ // defaults). `workspace` is newer than the other two — an older server omits it.
712
+ const proj = body?.project ?? body?.Project;
713
+ if (typeof proj === "string" && proj.length > 0) {
714
+ if (proj !== projectKey) {
715
+ console.error(
716
+ `[3/10] validate: key belongs to project '${proj}', not '${projectKey}'. Aborting.`,
717
+ );
718
+ process.exit(1);
719
+ }
720
+ log(`[3/10] validate: OK — key scoped to '${proj}'.`);
721
+ } else {
722
+ log(`[3/10] validate: 200 without a project field; continuing with a warning.`);
723
+ }
724
+ const ws = body?.workspace ?? body?.Workspace;
725
+ const projectValue = typeof proj === "string" ? proj : undefined;
726
+ const workspaceValue = typeof ws === "string" && ws.trim().length > 0 ? ws.trim() : undefined;
727
+ return {
728
+ ...(projectValue !== undefined ? { project: projectValue } : {}),
729
+ scopes: body?.scopes ?? body?.Scopes,
730
+ ...(workspaceValue !== undefined ? { workspace: workspaceValue } : {}),
731
+ };
732
+ }
733
+
734
+ // ---- step 5: stable kit copy -----------------------------------------------
735
+
736
+ // Short content fingerprint of every regular file under root (path + bytes, sorted). Used by
737
+ // `update` (and full wire's stable copy) so operators can see before/after kit identity without
738
+ // a package version (published package.json is often 0.0.0 until CI stamps it).
739
+ function kitFingerprint(root: string): string {
740
+ if (!existsSync(root)) return "(absent)";
741
+ const files: string[] = [];
742
+ const walk = (dir: string): void => {
743
+ for (const name of readdirSync(dir).sort()) {
744
+ const abs = join(dir, name);
745
+ const st = statSync(abs);
746
+ if (st.isDirectory()) walk(abs);
747
+ else if (st.isFile()) files.push(abs);
748
+ }
749
+ };
750
+ walk(root);
751
+ const h = createHash("sha256");
752
+ for (const abs of files) {
753
+ const rel = relative(root, abs).replace(/\\/g, "/");
754
+ h.update(rel);
755
+ h.update("\0");
756
+ h.update(readFileSync(abs));
757
+ h.update("\0");
758
+ }
759
+ return h.digest("hex").slice(0, 12);
760
+ }
761
+
762
+ type CopyKitResult = { before: string; after: string; skipped: boolean };
763
+
764
+ // Copy the running kit (HERE — an npx cache dir or a checkout's src/) into the stable location
765
+ // (~/.petbox/wire/), overwriting. Every global hook/plugin link is computed from STABLE, so the
766
+ // wiring keeps working after npx evicts its cache or a checkout moves. Copies the whole src dir
767
+ // (all .ts files + templates/). No-op when already running the installed copy.
768
+ // `label` prefixes log lines (full wire uses "[5/10]"; `update` uses "update").
769
+ function copyKitToStable(label: string = "[5/10]"): CopyKitResult {
770
+ const before = kitFingerprint(STABLE);
771
+ if (resolve(HERE) === resolve(STABLE)) {
772
+ log(`${label} stable copy: already running the installed kit at ${STABLE} — skipped.`);
773
+ return { before, after: before, skipped: true };
774
+ }
775
+ mkdirSync(STABLE, { recursive: true });
776
+ // Orphan cleanup — STABLE must be an EXACT MIRROR of HERE, never a UNION. cpSync overwrites but
777
+ // never DELETES, so a file the shipped kit dropped (e.g. the retired prompt-rag.ts / mcp-client.ts)
778
+ // would keep standing next to its NEWER peers → version skew: a leftover module importing a symbol
779
+ // the current registry.ts no longer exports → SyntaxError at hook time. Remove every top-level
780
+ // STABLE entry absent from HERE before copying, so the install can only ever match the shipped kit.
781
+ // (The settings-side half of that removal is pruneLegacyPromptRagHooks — files AND hooks must go.)
782
+ const hereEntries = new Set(readdirSync(HERE));
783
+ for (const name of readdirSync(STABLE)) {
784
+ if (!hereEntries.has(name)) {
785
+ rmSync(join(STABLE, name), { recursive: true, force: true });
786
+ log(`${label} orphan cleanup: removed ${name} from ${STABLE} (not shipped by this kit).`);
787
+ }
788
+ }
789
+ cpSync(HERE, STABLE, { recursive: true, force: true });
790
+ const after = kitFingerprint(STABLE);
791
+ log(`${label} stable copy: kit installed to ${STABLE} (from ${HERE}); hash ${before} → ${after}.`);
792
+ return { before, after, skipped: false };
793
+ }
794
+
795
+ // ---- migration: the retired prompt-RAG hook --------------------------------
796
+ //
797
+ // prompt-RAG is gone from the kit, but a machine that once ran `--prompt-rag` still has the hook
798
+ // command sitting in ~/.claude/settings.json and ~/.factory/settings.json, pointing at a
799
+ // prompt-rag.ts the kit no longer ships — which would fail on EVERY prompt. So: prune it
800
+ // UNCONDITIONALLY (no flag gates it any more) on every wire/update run. Idempotent by construction:
801
+ // the file is only rewritten when something was actually removed, so a second run is a byte-identical
802
+ // no-op. Other hooks in those files are never touched (see hook-prune.ts).
803
+ function pruneLegacyPromptRagHooks(label: string): void {
804
+ const targets: Array<[string, string]> = [
805
+ ["claude", join(homedir(), ".claude", "settings.json")],
806
+ ["droid", join(homedir(), ".factory", "settings.json")],
807
+ ];
808
+ for (const [agent, path] of targets) {
809
+ const settings = readJson(path);
810
+ if (!settings || typeof settings !== "object") continue;
811
+ if (!settings.hooks || typeof settings.hooks !== "object") continue;
812
+ const pruned = pruneDeadPromptRagHooks(settings.hooks);
813
+ if (pruned === 0) continue; // nothing to do → do not touch the file at all
814
+ writeJson(path, settings);
815
+ log(
816
+ `${label} migration: pruned ${pruned} dead ${agent} prompt-rag UserPromptSubmit hook(s) from ${path} ` +
817
+ `(the feature was removed; the hook pointed at a file the kit no longer ships).`,
818
+ );
819
+ }
820
+ }
821
+
822
+ // Safe kit-text refresh only: mirror THIS package into ~/.petbox/wire with orphan cleanup, plus the
823
+ // prompt-RAG hook migration (a refreshed kit drops prompt-rag.ts, so the dead hook must go with it).
824
+ // Intentionally does NOT: rotate/require API keys, touch ~/.petbox/keys.json or projects.json,
825
+ // (re)install any live hook, rewrite per-project MCP/skills, or flip the sticky telemetry flag.
826
+ // v1: STABLE kit only — re-run full wire to regenerate per-project skill bodies / MCP configs.
827
+ function runUpdate(argv: string[]): void {
828
+ // `update` takes no flags other than help; reject extras so typos don't silently no-op.
829
+ for (let i = 1; i < argv.length; i++) {
830
+ const a = argv[i];
831
+ if (a === undefined) continue; // unreachable: i < argv.length is the loop condition
832
+ if (a === "--help" || a === "-h") usage(0);
833
+ console.error(`update: unexpected argument: ${a}`);
834
+ usage();
835
+ }
836
+ log(`update: refreshing stable kit ${STABLE} from ${HERE}`);
837
+ log(`update: source hash ${kitFingerprint(HERE)}`);
838
+ const result = copyKitToStable("update:");
839
+ pruneLegacyPromptRagHooks("update:");
840
+ if (result.skipped) {
841
+ log(`update: done — kit already at ${STABLE} (hash ${result.after}).`);
842
+ } else if (result.before === result.after) {
843
+ log(`update: done — kit unchanged (hash ${result.after}).`);
844
+ } else {
845
+ log(`update: done — kit hash ${result.before} → ${result.after}.`);
846
+ }
847
+ log(
848
+ "update: skipped keys, registry, sticky telemetry, global hooks reinstall, " +
849
+ "and per-project MCP/skills (re-run full wire to refresh those).",
850
+ );
851
+ }
852
+
853
+ // ---- step 6: registry ------------------------------------------------------
854
+
855
+ // Reuse the envVar of an existing registry entry for this exact prefix, so a plain re-run
856
+ // stays idempotent even when the var name was customized via --env in the past.
857
+ function registryEnvVar(prefix: string): string | undefined {
858
+ const data = readJson(join(homedir(), ".petbox", "projects.json"));
859
+ const entries: any[] = Array.isArray(data?.entries) ? data.entries : [];
860
+ const norm = (p: string) => p.replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
861
+ const hit = entries.find((e) => norm(String(e?.prefix ?? "")) === norm(prefix));
862
+ const v = hit?.envVar;
863
+ return typeof v === "string" && v.length > 0 ? v : undefined;
864
+ }
865
+
866
+ // Upsert the registry entry for `prefix` — prefix/project/envVar (+ baseUrl when non-default).
867
+ // The entry is rewritten whole, so a retired key from an older kit (the removed `promptRag` gate)
868
+ // is dropped on the next wire rather than lingering as dead config.
869
+ function upsertRegistry(prefix: string, project: string, envVar: string, baseUrl: string): void {
870
+ const path = join(homedir(), ".petbox", "projects.json");
871
+ const data = readJson(path) ?? {};
872
+ const entries: any[] = Array.isArray(data.entries) ? data.entries : [];
873
+ const norm = (p: string) => p.replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
874
+ const np = norm(prefix);
875
+ const next = entries.filter((e) => norm(String(e?.prefix ?? "")) !== np);
876
+ const entry: any = { prefix, project, envVar };
877
+ if (baseUrl !== DEFAULT_BASE_URL) entry.baseUrl = baseUrl;
878
+ next.push(entry);
879
+ writeJson(path, { entries: next });
880
+ log(`[6/10] registry: upserted ${prefix} → ${project} (${envVar}) in ${path}`);
881
+ }
882
+
883
+ // ---- step 7: per-project files --------------------------------------------
884
+
885
+ // Merge one MCP server into a possibly-shared JSON config (Droid's .factory/mcp.json can hold
886
+ // team servers), preserving every other server and top-level key. Idempotent: re-running with
887
+ // the same inputs yields byte-identical output. Only the `petbox` entry is (re)generated.
888
+ function mergeMcpServer(path: string, name: string, server: unknown): void {
889
+ const data = readJson(path) ?? {};
890
+ if (!data.mcpServers || typeof data.mcpServers !== "object") data.mcpServers = {};
891
+ data.mcpServers[name] = server;
892
+ writeJson(path, data);
893
+ }
894
+
895
+ // Skill surfaces wire.ts writes the rendered petbox SKILL.md into. opencode is intentionally
896
+ // absent: it discovers the skill through its Claude-compatible path (`.claude/skills/…`), and a
897
+ // second same-name copy under `.opencode/skills/` would be a duplicate whose resolution opencode
898
+ // does not document. Droid reads its own `.factory/skills/` root (its compat path is
899
+ // `.agent/skills/`, NOT `.claude/skills/`), so it needs a dedicated copy.
900
+ const SKILL_SURFACES: string[][] = [
901
+ [".claude", "skills"], // Claude Code (native) + opencode (Claude-compatible discovery)
902
+ [".factory", "skills"], // Factory Droid (native)
903
+ ];
904
+
905
+ function writeProjectFiles(dir: string, project: string, envVar: string, workspace: string): void {
906
+ // .mcp.json (Claude Code) — petbox-only file owned by wire.ts, regenerated whole.
907
+ const mcp = {
908
+ mcpServers: {
909
+ petbox: {
910
+ type: "http",
911
+ url: `${DEFAULT_BASE_URL}/mcp`,
912
+ headers: { "X-Api-Key": `\${${envVar}}` },
913
+ },
914
+ },
915
+ };
916
+ writeJson(join(dir, ".mcp.json"), mcp);
917
+ log(`[7/10] wrote ${join(dir, ".mcp.json")}`);
918
+
919
+ // .opencode/opencode.json (opencode) — petbox-only file owned by wire.ts, regenerated whole.
920
+ const oc = {
921
+ $schema: "https://opencode.ai/config.json",
922
+ mcp: {
923
+ petbox: {
924
+ type: "remote",
925
+ url: `${DEFAULT_BASE_URL}/mcp`,
926
+ enabled: true,
927
+ headers: { "X-Api-Key": `{env:${envVar}}` },
928
+ },
929
+ },
930
+ };
931
+ writeJson(join(dir, ".opencode", "opencode.json"), oc);
932
+ log(`[7/10] wrote ${join(dir, ".opencode", "opencode.json")}`);
933
+
934
+ // .factory/mcp.json (Factory Droid) — a project-level MCP config that may be shared with team
935
+ // servers, so merge (never clobber) rather than regenerate whole. Droid supports `${VAR}`
936
+ // env-var expansion in header values, so the key stays out of the file (no secret committed).
937
+ const droidMcpPath = join(dir, ".factory", "mcp.json");
938
+ mergeMcpServer(droidMcpPath, "petbox", {
939
+ type: "http",
940
+ url: `${DEFAULT_BASE_URL}/mcp`,
941
+ headers: { "X-Api-Key": `\${${envVar}}` },
942
+ disabled: false,
943
+ });
944
+ log(`[7/10] merged petbox MCP server into ${droidMcpPath}`);
945
+
946
+ // SKILL.md — render once from the template, then drop a copy into every native skill surface.
947
+ const tpl = readFileSync(join(HERE, "templates", "SKILL.md"), "utf8");
948
+ const skill = tpl
949
+ .replace(/\{\{PROJECT\}\}/g, project)
950
+ .replace(/\{\{WORKSPACE\}\}/g, workspace);
951
+ for (const surface of SKILL_SURFACES) {
952
+ const skillPath = join(dir, ...surface, "petbox", "SKILL.md");
953
+ mkdirSync(dirname(skillPath), { recursive: true });
954
+ writeFileSync(skillPath, skill, "utf8");
955
+ log(`[7/10] wrote ${skillPath}`);
956
+ }
957
+
958
+ // Agent-factory skill — on-demand procedure (no project placeholders). Same surfaces as petbox.
959
+ const factoryTpl = readFileSync(join(HERE, "templates", "agent-factory", "SKILL.md"), "utf8");
960
+ for (const surface of SKILL_SURFACES) {
961
+ const skillPath = join(dir, ...surface, "petbox-agent-factory", "SKILL.md");
962
+ mkdirSync(dirname(skillPath), { recursive: true });
963
+ writeFileSync(skillPath, factoryTpl, "utf8");
964
+ log(`[7/10] wrote ${skillPath}`);
965
+ }
966
+ }
967
+
968
+ // ---- step 7b: telemetry (opt-in, --telemetry) ------------------------------
969
+
970
+ // Ensure the target named log exists. PetBox OTLP ingest is project+log-scoped in the PATH
971
+ // (`/v1/{metrics,logs}/{project}/{log}`) and returns 404 if the log is absent, so the log MUST
972
+ // pre-exist before Claude Code starts exporting. Idempotent: a 409 ("already exists") is success.
973
+ async function ensureTelemetryLog(
974
+ baseUrl: string,
975
+ project: string,
976
+ key: string,
977
+ logName: string,
978
+ ): Promise<void> {
979
+ const uri = `${baseUrl}/api/logs/${project}/logs`;
980
+ let resp: Response;
981
+ try {
982
+ resp = await fetch(uri, {
983
+ method: "POST",
984
+ headers: { "X-Api-Key": key, "Content-Type": "application/json" },
985
+ body: JSON.stringify({ name: logName }),
986
+ signal: AbortSignal.timeout(12000),
987
+ });
988
+ } catch (e) {
989
+ console.error(`[telemetry] could not reach ${uri} — ${(e as Error).message}. Aborting.`);
990
+ process.exit(1);
991
+ }
992
+ if (resp.ok || resp.status === 409) {
993
+ // 201 Created (fresh) or 409 Conflict (already exists) — both mean the log is ready.
994
+ log(`[telemetry] log '${logName}' ready in project '${project}' (HTTP ${resp.status}).`);
995
+ return;
996
+ }
997
+ const text = await resp.text().catch(() => "");
998
+ console.error(`[telemetry] failed to ensure log '${logName}' — HTTP ${resp.status} ${text}. Aborting.`);
999
+ process.exit(1);
1000
+ }
1001
+
1002
+ // Persist the OTLP export env for Claude Code, SPLIT by secrecy (per-project, NOT machine-scope:
1003
+ // machine env would make EVERY CC session on the box export):
1004
+ // - non-secret vars (endpoints, protocol, exporters, interval) → .claude/settings.json `env`;
1005
+ // - the API-key-bearing OTEL_EXPORTER_OTLP_HEADERS → .claude/settings.local.json `env` (the CC
1006
+ // local-override file, conventionally gitignored) — the raw key lands there, never in the
1007
+ // shareable settings.json.
1008
+ // Why the raw key and not `${envVar}`: Claude Code does NOT expand `${VAR}` inside settings.json
1009
+ // `env` values (unlike `.mcp.json`) — empirically verified 2026-07-06 — so a reference form sends
1010
+ // the literal string and the ingest returns 401. The key already lives plaintext in
1011
+ // ~/.petbox/keys.json; settings.local.json (gitignored) is the same trust boundary, per-project.
1012
+ // A literal key PINS the value: if the project api key rotates the header goes stale — re-run wire
1013
+ // (--telemetry) to re-provision. The header shape/name is built in buildTelemetryOtlpEnv (which the
1014
+ // unit test covers); this function only merges the result into the two files, preserving other
1015
+ // keys/env entries — only our OTEL_* / CLAUDE_* keys change.
1016
+ function writeTelemetrySettings(
1017
+ dir: string,
1018
+ project: string,
1019
+ key: string,
1020
+ logName: string,
1021
+ ): void {
1022
+ const { publicEnv, secretEnv } = buildTelemetryOtlpEnv(DEFAULT_BASE_URL, project, key, logName);
1023
+ // Non-secret export config → committable settings.json.
1024
+ mergeEnvIntoSettings(join(dir, ".claude", "settings.json"), publicEnv);
1025
+ log(`[telemetry] merged OTLP export config into .claude/settings.json (log '${logName}').`);
1026
+
1027
+ // Secret header (carries the API key) → gitignored settings.local.json.
1028
+ mergeEnvIntoSettings(join(dir, ".claude", "settings.local.json"), secretEnv);
1029
+ log(`[telemetry] wrote OTLP auth header into .claude/settings.local.json (gitignored — keep it out of git).`);
1030
+ }
1031
+
1032
+ // Merge an env map into a Claude Code settings file's `env` block, preserving all other keys/entries.
1033
+ function mergeEnvIntoSettings(settingsPath: string, envMap: Record<string, string>): void {
1034
+ const settings = readJson(settingsPath) ?? {};
1035
+ if (!settings.env || typeof settings.env !== "object") settings.env = {};
1036
+ for (const [k, v] of Object.entries(envMap)) settings.env[k] = v;
1037
+ writeJson(settingsPath, settings);
1038
+ }
1039
+
1040
+ // ---- step 8: global install ------------------------------------------------
1041
+
1042
+ // Hook commands are `node "<STABLE>/<file>.ts"`. Older wirings (this repo's own owner box
1043
+ // included) left commands pointing at a checkout — e.g. `node "D:\…\agents\wiring\push-session.ts"`.
1044
+ // Recognize a kit hook by these command suffixes so we can prune the stale ones (any that don't
1045
+ // equal one of this run's stable commands).
1046
+ const KIT_HOOK_SUFFIXES = [
1047
+ 'push-session.ts"',
1048
+ 'pull-memory.ts"',
1049
+ 'droid-push-session.ts"',
1050
+ 'droid-pull-memory.ts"',
1051
+ ];
1052
+
1053
+ // Remove kit hook entries whose command is NOT one of the current stable commands (validCmds),
1054
+ // then drop any now-empty groups. Mutates hooksObj in place; returns the count pruned.
1055
+ function pruneStaleKitHooks(hooksObj: any, validCmds: Set<string>): number {
1056
+ let removed = 0;
1057
+ for (const event of Object.keys(hooksObj)) {
1058
+ const groups: any[] = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
1059
+ for (const g of groups) {
1060
+ if (!g || !Array.isArray(g.hooks)) continue;
1061
+ const before = g.hooks.length;
1062
+ g.hooks = g.hooks.filter((h: any) => {
1063
+ const c = typeof h?.command === "string" ? h.command : "";
1064
+ const isKit = KIT_HOOK_SUFFIXES.some((s) => c.endsWith(s));
1065
+ return !(isKit && !validCmds.has(c));
1066
+ });
1067
+ removed += before - g.hooks.length;
1068
+ }
1069
+ hooksObj[event] = groups.filter((g) => !(g && Array.isArray(g.hooks) && g.hooks.length === 0));
1070
+ }
1071
+ return removed;
1072
+ }
1073
+
1074
+ // Install the live kit hooks (Stop / SessionStart on both agents) and, on the way through, run the
1075
+ // retired-prompt-RAG migration on each settings object before it is written back — one read, one
1076
+ // write per file, so the prune costs nothing extra and cannot be skipped.
1077
+ function installGlobalHooks(): void {
1078
+ const pushCmd = `node "${join(STABLE, "push-session.ts")}"`;
1079
+ const pullCmd = `node "${join(STABLE, "pull-memory.ts")}"`;
1080
+ const droidPushCmd = `node "${join(STABLE, "droid-push-session.ts")}"`;
1081
+ const droidPullCmd = `node "${join(STABLE, "droid-pull-memory.ts")}"`;
1082
+ // Every kit hook command this run considers current — the prune keeps these, drops the rest.
1083
+ const validCmds = new Set([pushCmd, pullCmd, droidPushCmd, droidPullCmd]);
1084
+
1085
+ const settingsPath = join(homedir(), ".claude", "settings.json");
1086
+ const settings = readJson(settingsPath) ?? {};
1087
+ if (!settings.hooks || typeof settings.hooks !== "object") settings.hooks = {};
1088
+ const prunedClaude = pruneStaleKitHooks(settings.hooks, validCmds);
1089
+ if (prunedClaude > 0) log(`[8/10] pruned ${prunedClaude} stale claude kit hook(s) not pointing at ${STABLE}.`);
1090
+
1091
+ // Claude Code hooks shape: settings.hooks[event] = [{ matcher?, hooks: [{type, command}] }]
1092
+ const ensureHook = (event: string, command: string) => {
1093
+ const groups: any[] = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
1094
+ const already = groups.some(
1095
+ (g) => Array.isArray(g?.hooks) && g.hooks.some((h: any) => h?.command === command),
1096
+ );
1097
+ if (already) {
1098
+ log(`[8/10] claude hook ${event} already present — skipped.`);
1099
+ return;
1100
+ }
1101
+ groups.push({ hooks: [{ type: "command", command }] });
1102
+ settings.hooks[event] = groups;
1103
+ log(`[8/10] claude hook ${event} added.`);
1104
+ };
1105
+
1106
+ ensureHook("Stop", pushCmd);
1107
+ ensureHook("SessionStart", pullCmd);
1108
+ // Migration (unconditional): drop any leftover prompt-rag UserPromptSubmit hook — the feature is
1109
+ // gone and the kit no longer ships the file its command points at.
1110
+ const ragPrunedClaude = pruneDeadPromptRagHooks(settings.hooks);
1111
+ if (ragPrunedClaude > 0) {
1112
+ log(`[8/10] pruned ${ragPrunedClaude} dead claude prompt-rag UserPromptSubmit hook(s) (feature removed).`);
1113
+ }
1114
+ writeJson(settingsPath, settings);
1115
+ log(`[8/10] merged hooks into ${settingsPath}`);
1116
+
1117
+ // Factory Droid hooks: same JSON shape as Claude Code, merged into ~/.factory/settings.json
1118
+ // under the `hooks` key (a documented fallback location). Droid exposes petbox tools as
1119
+ // `mcp__petbox__*` and delivers Claude-Code-compatible snake_case payloads, so it reuses the
1120
+ // shared protocol/append flow via its own thin hooks. No `enableHooks` flag is set: the droid
1121
+ // hooks reference does not document one gating hook execution.
1122
+ const droidSettingsPath = join(homedir(), ".factory", "settings.json");
1123
+ const droidSettings = readJson(droidSettingsPath) ?? {};
1124
+ if (!droidSettings.hooks || typeof droidSettings.hooks !== "object") droidSettings.hooks = {};
1125
+ const prunedDroid = pruneStaleKitHooks(droidSettings.hooks, validCmds);
1126
+ if (prunedDroid > 0) log(`[8/10] pruned ${prunedDroid} stale droid kit hook(s) not pointing at ${STABLE}.`);
1127
+
1128
+ const ensureDroidHook = (event: string, command: string) => {
1129
+ const groups: any[] = Array.isArray(droidSettings.hooks[event]) ? droidSettings.hooks[event] : [];
1130
+ const already = groups.some(
1131
+ (g) => Array.isArray(g?.hooks) && g.hooks.some((h: any) => h?.command === command),
1132
+ );
1133
+ if (already) {
1134
+ log(`[8/10] droid hook ${event} already present — skipped.`);
1135
+ return;
1136
+ }
1137
+ groups.push({ hooks: [{ type: "command", command }] });
1138
+ droidSettings.hooks[event] = groups;
1139
+ log(`[8/10] droid hook ${event} added.`);
1140
+ };
1141
+
1142
+ ensureDroidHook("Stop", droidPushCmd);
1143
+ ensureDroidHook("SessionStart", droidPullCmd);
1144
+ // Same migration on the Droid side. Its legacy command carried an `--agent droid` suffix, which is
1145
+ // why the prune matches the QUOTED BASENAME (`prompt-rag.ts"`) and catches both variants.
1146
+ const ragPrunedDroid = pruneDeadPromptRagHooks(droidSettings.hooks);
1147
+ if (ragPrunedDroid > 0) {
1148
+ log(`[8/10] pruned ${ragPrunedDroid} dead droid prompt-rag UserPromptSubmit hook(s) (feature removed).`);
1149
+ }
1150
+ writeJson(droidSettingsPath, droidSettings);
1151
+ log(`[8/10] merged droid hooks into ${droidSettingsPath}`);
1152
+
1153
+ // Global opencode plugin: thin shim re-exporting the kit plugin from the stable copy's file
1154
+ // URL (overwritten each run, so an old shim pointing at a checkout is replaced).
1155
+ const pluginAbs = join(STABLE, "opencode-plugin.ts");
1156
+ const pluginUrl = toFileUrl(pluginAbs);
1157
+ const shimDir = join(homedir(), ".config", "opencode", "plugins");
1158
+ mkdirSync(shimDir, { recursive: true });
1159
+ const shimPath = join(shimDir, "petbox.ts");
1160
+ const shim = `// Auto-generated by wire.ts — global PetBox opencode plugin shim.
1161
+ // Re-exports the kit plugin from its absolute path so a single source of truth serves
1162
+ // every project (the active project is resolved from cwd via the shared registry).
1163
+ export { PetboxPlugin, default } from "${pluginUrl}";
1164
+ `;
1165
+ writeFileSync(shimPath, shim, "utf8");
1166
+ log(`[8/10] wrote global opencode plugin shim ${shimPath} → ${pluginUrl}`);
1167
+ }
1168
+
1169
+ // ---- step 9: cleanup legacy ------------------------------------------------
1170
+
1171
+ function cleanupLegacy(dir: string): void {
1172
+ // .claude/hooks/ — drop the whole per-project hooks folder.
1173
+ const hooksDir = join(dir, ".claude", "hooks");
1174
+ if (existsSync(hooksDir)) {
1175
+ rmSync(hooksDir, { recursive: true, force: true });
1176
+ log(`[9/10] removed ${hooksDir}`);
1177
+ }
1178
+
1179
+ // .claude/settings.local.json — drop ONLY the hooks key, keep permissions etc.
1180
+ const localPath = join(dir, ".claude", "settings.local.json");
1181
+ const local = readJson(localPath);
1182
+ if (local && typeof local === "object" && "hooks" in local) {
1183
+ delete local.hooks;
1184
+ writeJson(localPath, local);
1185
+ log(`[9/10] removed 'hooks' key from ${localPath}`);
1186
+ }
1187
+
1188
+ // .opencode/plugin/ — drop the per-project plugin folder.
1189
+ const pluginDir = join(dir, ".opencode", "plugin");
1190
+ if (existsSync(pluginDir)) {
1191
+ rmSync(pluginDir, { recursive: true, force: true });
1192
+ log(`[9/10] removed ${pluginDir}`);
1193
+ }
1194
+
1195
+ // .opencode node deps — only if package.json depends solely on @opencode-ai/plugin.
1196
+ const ocPkgPath = join(dir, ".opencode", "package.json");
1197
+ const ocPkg = readJson(ocPkgPath);
1198
+ if (ocPkg) {
1199
+ const deps = { ...(ocPkg.dependencies ?? {}), ...(ocPkg.devDependencies ?? {}) };
1200
+ const keys = Object.keys(deps);
1201
+ const onlyPlugin = keys.length > 0 && keys.every((k) => k === "@opencode-ai/plugin");
1202
+ const noDeps = keys.length === 0;
1203
+ if (onlyPlugin || noDeps) {
1204
+ for (const f of ["package.json", "bun.lock", "node_modules"]) {
1205
+ const p = join(dir, ".opencode", f);
1206
+ if (existsSync(p)) {
1207
+ rmSync(p, { recursive: true, force: true });
1208
+ log(`[9/10] removed ${p}`);
1209
+ }
1210
+ }
1211
+ } else {
1212
+ log(`[9/10] kept .opencode deps — package.json has non-plugin deps: ${keys.join(", ")}`);
1213
+ }
1214
+ }
1215
+ }
1216
+
1217
+ // ---- step 10: self-smoke ---------------------------------------------------
1218
+
1219
+ // Returns whether the smoke succeeded — the caller (main()) uses this to decide whether "done."
1220
+ // is allowed to print (selfsmoke-failure-prints-done: a failed smoke must never be followed by
1221
+ // a line that reads like success). Response classification itself lives in self-smoke.ts so it
1222
+ // is unit-testable without a network call; this wrapper only owns the fetch + exit-code side effect.
1223
+ async function selfSmoke(baseUrl: string, project: string, key: string): Promise<boolean> {
1224
+ const uri = `${baseUrl}/api/sessions/${project}/wire-smoke?agent=wire`;
1225
+ const body = JSON.stringify({ role: "user", content: "wire.ts self-smoke — verifying the session push pipeline." });
1226
+ let resp: Response;
1227
+ try {
1228
+ resp = await fetch(uri, {
1229
+ method: "POST",
1230
+ headers: { "X-Api-Key": key, "Content-Type": "application/x-ndjson; charset=utf-8" },
1231
+ body,
1232
+ signal: AbortSignal.timeout(12000),
1233
+ });
1234
+ } catch (e) {
1235
+ console.error(`[10/10] self-smoke: POST failed — ${(e as Error).message}`);
1236
+ process.exitCode = 1;
1237
+ return false;
1238
+ }
1239
+ const text = await resp.text();
1240
+ const result = classifySelfSmokeResponse(resp.ok, resp.status, text);
1241
+ if (result.ok) {
1242
+ log(result.message);
1243
+ } else {
1244
+ console.error(result.message);
1245
+ process.exitCode = 1;
1246
+ }
1247
+ return result.ok;
1248
+ }
1249
+
1250
+ // ---- main ------------------------------------------------------------------
1251
+
1252
+ async function main(): Promise<void> {
1253
+ const argv = process.argv.slice(2);
1254
+ // Subcommands that need no project/key. Must run before parseArgs, which requires
1255
+ // <dir> <projectKey> positionals for the full wire path.
1256
+ if (isUpdateCommand(argv)) {
1257
+ runUpdate(argv);
1258
+ return;
1259
+ }
1260
+ if (isDoctorCommand(argv)) {
1261
+ await runDoctor(argv);
1262
+ return;
1263
+ }
1264
+ if (isApplyCommand(argv)) {
1265
+ await runApply(argv);
1266
+ return;
1267
+ }
1268
+ if (isRolesCommand(argv)) {
1269
+ runRoles(argv);
1270
+ return;
1271
+ }
1272
+ if (isProfileCommand(argv)) {
1273
+ runProfile(argv);
1274
+ return;
1275
+ }
1276
+
1277
+ const args = parseArgs(argv);
1278
+ const dir = resolve(args.dir);
1279
+ const project = args.projectKey;
1280
+ const baseUrl = DEFAULT_BASE_URL;
1281
+
1282
+ if (!existsSync(dir)) {
1283
+ console.error(`directory does not exist: ${dir}`);
1284
+ process.exit(1);
1285
+ }
1286
+
1287
+ // 1. env var — explicit --env wins; else reuse the existing registry entry (idempotent
1288
+ // re-run with a customized var name); else derive from the project key.
1289
+ const envVar = args.env ?? registryEnvVar(dir) ?? deriveEnvVar(project);
1290
+ log(`[1/10] envVar = ${envVar}`);
1291
+
1292
+ // 2. key — --key wins, else process env (owner's inherited user-scope var still works),
1293
+ // else the cross-platform key store (~/.petbox/keys.json).
1294
+ let key = args.key;
1295
+ if (key) {
1296
+ log(`[2/10] using --key from the command line.`);
1297
+ } else {
1298
+ key = process.env[envVar] || readKeyFromStore(envVar) || "";
1299
+ if (!key) {
1300
+ console.error(
1301
+ `[2/10] no API key found.\n` +
1302
+ ` Provide one with --key <KEY>, or set ${envVar} (env or ~/.petbox/keys.json) first.\n` +
1303
+ ` A key for a NEW project has no agent on it yet, so it can't mint its own key —\n` +
1304
+ ` ask a workspace admin to mint one on the project's Connect page:\n` +
1305
+ ` /ui/admin/ws/<workspace>/projects/${project}/connect (mint only happens there)\n` +
1306
+ ` Then re-run with --key <KEY>. (Minting keys is out of scope for wire.ts.)`,
1307
+ );
1308
+ process.exit(1);
1309
+ }
1310
+ log(`[2/10] using existing ${envVar} (env or key store).`);
1311
+ }
1312
+
1313
+ // 3. validate — BEFORE persisting anything, so a bad key never lands in the stores.
1314
+ const validated = await validateKey(baseUrl, key, project);
1315
+
1316
+ // 3b. workspace for the skill template ({{WORKSPACE}}): --workspace overrides the workspace the
1317
+ // server reports at /api/auth/validate; there is NO hardcoded default. Resolved BEFORE any
1318
+ // persistence so an unresolvable workspace leaves the machine untouched.
1319
+ const ws = resolveWorkspace(args.workspace, validated?.workspace);
1320
+ if (!ws.ok) {
1321
+ console.error(ws.message);
1322
+ process.exit(ws.exitCode);
1323
+ }
1324
+ const workspace = ws.workspace;
1325
+ log(
1326
+ `[3/10] workspace = ${workspace} (${ws.source === "flag" ? "--workspace" : "reported by /api/auth/validate"}).`,
1327
+ );
1328
+
1329
+ // 4. persist everywhere agents look: keys.json (kit hooks read it immediately) + a real
1330
+ // env var per platform (the per-project MCP configs reference ${envVar}). Idempotent, so
1331
+ // re-runs self-heal a machine where only one of the two exists.
1332
+ writeKeyToStore(envVar, key);
1333
+ log(`[4/10] persisted ${envVar} to ${keysStorePath()}.`);
1334
+ persistKeyForAgents(envVar);
1335
+
1336
+ // 5. stable kit copy
1337
+ copyKitToStable();
1338
+
1339
+ // 6. registry
1340
+ upsertRegistry(dir, project, envVar, baseUrl);
1341
+
1342
+ // 7. project files
1343
+ writeProjectFiles(dir, project, envVar, workspace);
1344
+
1345
+ // 7b. telemetry (opt-in): ensure the target log exists, then persist the OTLP export env into
1346
+ // the project's .claude/settings.json. Off by default — only when --telemetry is passed.
1347
+ // opencode/droid are intentionally NOT wired: their OTLP exporters append `/v1/{signal}` to a
1348
+ // base endpoint and cannot carry the project/log path PetBox's ingest requires — CC-only.
1349
+ if (args.telemetry) {
1350
+ await ensureTelemetryLog(baseUrl, project, key, args.telemetryLog);
1351
+ writeTelemetrySettings(dir, project, key, args.telemetryLog);
1352
+ } else {
1353
+ log(`[telemetry] not requested — skipped (pass --telemetry to enable Claude Code OTLP export).`);
1354
+ }
1355
+
1356
+ // 8. global install — installs the live Stop/SessionStart hooks and, unconditionally, prunes the
1357
+ // dead prompt-rag UserPromptSubmit hook left behind by a kit that still had the feature.
1358
+ installGlobalHooks();
1359
+
1360
+ // 9. cleanup legacy
1361
+ if (args.cleanupLegacy) cleanupLegacy(dir);
1362
+ else log(`[9/10] cleanup-legacy not requested — skipped.`);
1363
+
1364
+ // 10. self-smoke
1365
+ const smokeOk = await selfSmoke(baseUrl, project, key);
1366
+
1367
+ // Terminal message set depends on the smoke outcome — a failure must be the LAST line, in
1368
+ // red, never followed by "done." (selfsmoke-failure-prints-done).
1369
+ const finish = finishWireRun({
1370
+ smokeOk,
1371
+ envVar,
1372
+ envVarPresentInProcess: !!process.env[envVar],
1373
+ platform: process.platform,
1374
+ });
1375
+ for (const line of finish.lines) {
1376
+ if (finish.toStderr) console.error(line);
1377
+ else log(line);
1378
+ }
1379
+ }
1380
+
1381
+ main().catch((e) => {
1382
+ console.error(e?.stack ?? String(e));
1383
+ process.exit(1);
1384
+ });