@tpsdev-ai/flair 0.53.0 → 0.54.2

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 (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1665 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +931 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1628 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/engine-version.js +12 -4
  43. package/dist/fabric-npm-install.js +87 -0
  44. package/dist/fabric-upgrade.js +30 -15
  45. package/dist/federation-verify.js +498 -0
  46. package/dist/fleet-verify.js +144 -21
  47. package/dist/install/clients.js +167 -0
  48. package/dist/lib/auth-resolve.js +76 -1
  49. package/dist/lib/daemon-liveness.js +131 -2
  50. package/dist/lib/doctor-config-path.js +61 -0
  51. package/dist/lib/doctor-federation-driver.js +189 -0
  52. package/dist/lib/doctor-run.js +40 -0
  53. package/dist/lib/entity-vocab-cli.js +3 -3
  54. package/dist/lib/federation-pair-identity.js +47 -0
  55. package/dist/lib/launchd-repair.js +5 -4
  56. package/dist/lib/npm-registry.js +578 -0
  57. package/dist/lib/ops-api-bind.js +115 -0
  58. package/dist/lib/owned-pins.js +219 -0
  59. package/dist/lib/uninstall-purge.js +218 -0
  60. package/dist/rem/restore.js +8 -10
  61. package/dist/resources/AgentReadPosition.js +74 -0
  62. package/dist/resources/Federation.js +8 -2
  63. package/dist/resources/Memory.js +4 -3
  64. package/dist/resources/MemoryBootstrap.js +41 -25
  65. package/dist/resources/MemoryCandidate.js +5 -6
  66. package/dist/resources/OrgEventCatchup.js +126 -47
  67. package/dist/resources/agent-read-position-lib.js +83 -0
  68. package/dist/resources/agent-read-position.js +120 -0
  69. package/dist/resources/embeddings-boot.js +32 -0
  70. package/dist/resources/federation-peer-liveness.js +73 -0
  71. package/dist/resources/health.js +68 -19
  72. package/dist/resources/mcp-tools.js +48 -279
  73. package/dist/resources/memory-visibility.js +3 -3
  74. package/dist/resources/migration-boot.js +59 -18
  75. package/dist/resources/migrations/embedding-stamp.js +20 -1
  76. package/dist/resources/migrations/recheck.js +43 -0
  77. package/dist/resources/migrations/runner.js +6 -1
  78. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  79. package/dist/resources/migrations/visibility-backfill.js +2 -2
  80. package/dist/resources/org-event-catchup-lib.js +47 -0
  81. package/dist/resources/record-owner-guard.js +1 -0
  82. package/dist/resources/tool-descriptors/index.js +669 -0
  83. package/dist/stamp-migration-verify.js +163 -0
  84. package/dist/stamp-outstanding.js +144 -0
  85. package/dist/version-check.js +29 -8
  86. package/docs/api-reference.md +4 -2
  87. package/docs/deploying-on-fabric.md +11 -10
  88. package/docs/deployment.md +3 -1
  89. package/docs/federation.md +19 -0
  90. package/docs/hosted-on-fabric.md +3 -3
  91. package/docs/quickstart.md +2 -1
  92. package/docs/releasing.md +20 -6
  93. package/docs/spoke-bringup.md +10 -5
  94. package/docs/standalone-local.md +3 -1
  95. package/docs/upgrade.md +25 -6
  96. package/package.json +4 -4
  97. package/schemas/agent.graphql +15 -0
@@ -0,0 +1,1203 @@
1
+ import { applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook } from "../doctor-client.js";
2
+ import { hookSettingsPath } from "../hook-install.js";
3
+ import { detectClients, renderWiringSummary, wireAntigravity, wireCodex, wireCursor, wireGemini, wirePi } from "../install/clients.js";
4
+ import { DEFAULT_ADMIN_USER, authFetch, defaultKeysDir, readAdminPassFileSecure, resolveAdminUser } from "../lib/auth-resolve.js";
5
+ import { mcpServerSpec, unpinnedSpecWarning } from "../lib/mcp-spec.js";
6
+ import * as render from "../render.js";
7
+ import { execSync, spawn } from "node:child_process";
8
+ import { randomBytes, randomUUID } from "node:crypto";
9
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import nacl from "tweetnacl";
13
+ let cli;
14
+ /** Bind the cli-locals this module depends on. */
15
+ export function bindCli(fns) {
16
+ cli = fns;
17
+ }
18
+ function api(...args) {
19
+ return cli.api(...args);
20
+ }
21
+ function b64url(...args) {
22
+ return cli.b64url(...args);
23
+ }
24
+ function buildLaunchdPlist(...args) {
25
+ return cli.buildLaunchdPlist(...args);
26
+ }
27
+ function buildOperationsApiConfig(...args) {
28
+ return cli.buildOperationsApiConfig(...args);
29
+ }
30
+ function cleanupLegacyLaunchdPlist(...args) {
31
+ return cli.cleanupLegacyLaunchdPlist(...args);
32
+ }
33
+ function defaultDataDir(...args) {
34
+ return cli.defaultDataDir(...args);
35
+ }
36
+ function defaultLaunchAgentsDir(...args) {
37
+ return cli.defaultLaunchAgentsDir(...args);
38
+ }
39
+ function ensureFlairAgentRole(...args) {
40
+ return cli.ensureFlairAgentRole(...args);
41
+ }
42
+ function ensureFlairAgentUser(...args) {
43
+ return cli.ensureFlairAgentUser(...args);
44
+ }
45
+ function ensureFlairPairInitiatorRole(...args) {
46
+ return cli.ensureFlairPairInitiatorRole(...args);
47
+ }
48
+ function flairPackageDir(...args) {
49
+ return cli.flairPackageDir(...args);
50
+ }
51
+ function harperBin(...args) {
52
+ return cli.harperBin(...args);
53
+ }
54
+ function harperConfigPath(...args) {
55
+ return cli.harperConfigPath(...args);
56
+ }
57
+ function launchdLabel(...args) {
58
+ return cli.launchdLabel(...args);
59
+ }
60
+ function launchdPlistPath(...args) {
61
+ return cli.launchdPlistPath(...args);
62
+ }
63
+ function opsNetworkPortValue(...args) {
64
+ return cli.opsNetworkPortValue(...args);
65
+ }
66
+ function persistDefaultInstallCoordinates(...args) {
67
+ return cli.persistDefaultInstallCoordinates(...args);
68
+ }
69
+ function privKeyPath(...args) {
70
+ return cli.privKeyPath(...args);
71
+ }
72
+ function provisionFabric(...args) {
73
+ return cli.provisionFabric(...args);
74
+ }
75
+ function pubKeyPath(...args) {
76
+ return cli.pubKeyPath(...args);
77
+ }
78
+ function readyOpsSocketPosture(...args) {
79
+ return cli.readyOpsSocketPosture(...args);
80
+ }
81
+ function resolveHttpPort(...args) {
82
+ return cli.resolveHttpPort(...args);
83
+ }
84
+ function resolveInitAdminPasswordSource(...args) {
85
+ return cli.resolveInitAdminPasswordSource(...args);
86
+ }
87
+ function resolveOpsBindHost(...args) {
88
+ return cli.resolveOpsBindHost(...args);
89
+ }
90
+ function resolveOpsPort(...args) {
91
+ return cli.resolveOpsPort(...args);
92
+ }
93
+ function resolveOpsTarget(...args) {
94
+ return cli.resolveOpsTarget(...args);
95
+ }
96
+ function resolveOpsUrlFromTarget(...args) {
97
+ return cli.resolveOpsUrlFromTarget(...args);
98
+ }
99
+ function resolveTarget(...args) {
100
+ return cli.resolveTarget(...args);
101
+ }
102
+ function runSoulWizard(...args) {
103
+ return cli.runSoulWizard(...args);
104
+ }
105
+ function seedAgentViaOpsApi(...args) {
106
+ return cli.seedAgentViaOpsApi(...args);
107
+ }
108
+ function seedFederationInstanceViaOpsApi(...args) {
109
+ return cli.seedFederationInstanceViaOpsApi(...args);
110
+ }
111
+ function shouldShowInlineSecretWarning(...args) {
112
+ return cli.shouldShowInlineSecretWarning(...args);
113
+ }
114
+ function verifyAuditLog(...args) {
115
+ return cli.verifyAuditLog(...args);
116
+ }
117
+ function verifySemanticSearch(...args) {
118
+ return cli.verifySemanticSearch(...args);
119
+ }
120
+ function waitForHealth(...args) {
121
+ return cli.waitForHealth(...args);
122
+ }
123
+ function writeDaemonSidecar(...args) {
124
+ return cli.writeDaemonSidecar(...args);
125
+ }
126
+ export function register(program) {
127
+ const MQTT_DISABLED_CONFIG = cli.MQTT_DISABLED_CONFIG;
128
+ const STARTUP_TIMEOUT_MS = cli.STARTUP_TIMEOUT_MS;
129
+ // ─── flair init ──────────────────────────────────────────────────────────────
130
+ program
131
+ .command("init")
132
+ .description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
133
+ .option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
134
+ .option("--agent <id>", "Alias for --agent-id")
135
+ // No commander default (flair#928). A default here is indistinguishable from
136
+ // the user typing it, so a BARE `flair init` used to state DEFAULT_PORT and
137
+ // renumber an instance already serving a custom one. Absent means absent, and
138
+ // resolveHttpPort's "create" ladder supplies DEFAULT_PORT for a genuinely new
139
+ // instance — which is the only case that ever wanted one.
140
+ .option("--port <port>", "Harper HTTP port (default: this instance's current port, or 19926 for a new one)")
141
+ .option("--ops-port <port>", "Harper operations API port")
142
+ .option("--ops-bind <addr>", "Harper ops API bind address (env: FLAIR_OPS_BIND; default: 127.0.0.1 loopback-only for single-host — pass e.g. 0.0.0.0 for multi-host/Fabric remote admin)")
143
+ .option("--admin-pass <pass>", "Admin password (generated if omitted)")
144
+ .option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
145
+ .option("--admin-user <name>", "Admin username when authenticating to an already-running instance via --target/--ops-target (env: FLAIR_ADMIN_USER; default: admin — local bootstrap and Fabric provisioning always create 'admin')")
146
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
147
+ .option("--data-dir <dir>", "Harper data directory")
148
+ .option("--skip-start", "Skip Harper startup (assume already running)")
149
+ .option("--skip-soul", "Skip interactive personality setup")
150
+ .option("--client <client>", "Client(s) to wire: claude-code, codex, gemini, cursor, antigravity, pi (native extension), all, or none")
151
+ .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
152
+ .option("--skip-smoke", "Skip the MCP smoke test")
153
+ .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
154
+ .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code and Codex)")
155
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
156
+ .option("--remote", "When used with --target, init as hub for remote federation")
157
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
158
+ .option("--force", "Skip confirmation prompt for remote writes (required with --target)")
159
+ .option("--cluster-admin-user <user>", "Harper cluster admin username (env: FLAIR_CLUSTER_ADMIN_USER)")
160
+ .option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
161
+ .option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
162
+ .action(async (opts) => {
163
+ const agentId = opts.agentId ?? opts.agent;
164
+ const target = resolveTarget(opts);
165
+ const opsTarget = resolveOpsTarget(opts);
166
+ // ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
167
+ if (target || opsTarget) {
168
+ // When -only- --ops-target is provided, attempt to derive REST URL
169
+ if (!target && opsTarget) {
170
+ console.error("Error: --ops-target requires --target as well. Pass --target <rest-url> for the REST API surface.");
171
+ console.error(" Currently only explicit --ops-target + --target combination is supported.");
172
+ process.exit(1);
173
+ }
174
+ const baseUrl = target.replace(/\/$/, "");
175
+ // --ops-target overrides derivation; otherwise derive from --target
176
+ const opsUrl = opsTarget ? opsTarget.replace(/\/$/, "") : resolveOpsUrlFromTarget(baseUrl);
177
+ // Check for cluster-admin provisioning (new atomic flow)
178
+ const clusterAdminUser = opts.clusterAdminUser || process.env.FLAIR_CLUSTER_ADMIN_USER;
179
+ const clusterAdminPass = opts.clusterAdminPass || process.env.FLAIR_CLUSTER_ADMIN_PASS;
180
+ let flairAdminPass = opts.flairAdminPass || process.env.FLAIR_ADMIN_PASS;
181
+ let didProvision = false;
182
+ if (clusterAdminUser && clusterAdminPass) {
183
+ // ── New provisioning path: deploy Flair to Fabric, wait, provision super_user ──
184
+ if (!opts.force) {
185
+ console.error("Error: --force is required with --target/--ops-target (remote init provisions a live Fabric instance)");
186
+ console.error(" Pass --force to confirm this is intended.");
187
+ process.exit(1);
188
+ }
189
+ // Generate flair admin pass if not provided
190
+ if (!flairAdminPass) {
191
+ flairAdminPass = randomBytes(24).toString("base64url");
192
+ }
193
+ // Write the flair admin pass to secrets directory
194
+ const secretsDir = join(homedir(), ".tps", "secrets");
195
+ mkdirSync(secretsDir, { recursive: true });
196
+ const secretPath = join(secretsDir, "flair-fabric-hdb");
197
+ writeFileSync(secretPath, flairAdminPass + "\n", { mode: 0o600 });
198
+ console.log(`Admin password written to ${secretPath}`);
199
+ // Atomic provisioning: deploy + wait + provision user
200
+ await provisionFabric(baseUrl, opsUrl, clusterAdminUser, clusterAdminPass, flairAdminPass);
201
+ didProvision = true;
202
+ // Hub instances (--remote) receive federation pair requests and need
203
+ // the flair_pair_initiator role so bootstrap credentials can pass
204
+ // platform auth before reaching the FederationPair resource handler.
205
+ if (opts.remote) {
206
+ await ensureFlairPairInitiatorRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
207
+ }
208
+ // Every flair instance has agents, so provision the least-privilege
209
+ // flair_agent role (idempotent, harmless until a user is assigned to it).
210
+ await ensureFlairAgentRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
211
+ // THE FLIP (auth-rbac): provision the shared least-privilege flair-agent user.
212
+ // This ACTIVATES the gate's per-agent de-elevation (verified non-admin agents
213
+ // resolve to flair-agent instead of admin super_user). Safe now: #487 gave
214
+ // every agent-facing resource its own allow* + resolveAgentAuth, so they no
215
+ // longer rely on the admin super_user bypass. The gate also falls back to
216
+ // admin if this user is ever absent, so de-elevation degrades gracefully.
217
+ await ensureFlairAgentUser(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
218
+ }
219
+ else {
220
+ // ── Existing behavior: --admin-pass required for already-running Flair ──
221
+ if (!opts.adminPass) {
222
+ console.error("Error: --admin-pass is required with --target/--ops-target (remote init without --cluster-admin-user/--cluster-admin-pass)");
223
+ console.error(" Use --cluster-admin-user and --cluster-admin-pass for automated Fabric provisioning.");
224
+ process.exit(1);
225
+ }
226
+ if (!opts.force) {
227
+ const displayTarget = target || opsTarget;
228
+ console.error(`Error: --force is required with --target/--ops-target. Remote init writes to a live Flair instance at ${displayTarget}.`);
229
+ console.error(" Pass --force to confirm this is intended.");
230
+ process.exit(1);
231
+ }
232
+ flairAdminPass = opts.adminPass;
233
+ }
234
+ // flair#1345: only the already-running-instance leg honors --admin-user /
235
+ // FLAIR_ADMIN_USER — the provisioning leg just CREATED the superuser as
236
+ // DEFAULT_ADMIN_USER via provisionFabric, so that name is ground truth.
237
+ const adminUser = didProvision ? DEFAULT_ADMIN_USER : resolveAdminUser(opts.adminUser);
238
+ const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
239
+ const role = opts.remote ? "hub" : undefined;
240
+ // Generate or reuse keypair (only if --agent-id provided, or --remote needs
241
+ // a public key for the FederationInstance row)
242
+ let pubKeyB64url;
243
+ let privPath;
244
+ let instanceId;
245
+ if (agentId || role) {
246
+ const keysDir = opts.keysDir ?? defaultKeysDir();
247
+ mkdirSync(keysDir, { recursive: true });
248
+ if (agentId) {
249
+ privPath = privKeyPath(agentId, keysDir);
250
+ const pubPath = pubKeyPath(agentId, keysDir);
251
+ if (existsSync(privPath)) {
252
+ console.log(`Reusing existing key: ${privPath}`);
253
+ const seed = new Uint8Array(readFileSync(privPath));
254
+ const kp = nacl.sign.keyPair.fromSeed(seed);
255
+ pubKeyB64url = b64url(kp.publicKey);
256
+ }
257
+ else {
258
+ console.log("Generating Ed25519 keypair...");
259
+ const kp = nacl.sign.keyPair();
260
+ const seed = kp.secretKey.slice(0, 32);
261
+ writeFileSync(privPath, Buffer.from(seed));
262
+ chmodSync(privPath, 0o600);
263
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
264
+ pubKeyB64url = b64url(kp.publicKey);
265
+ console.log(`Keypair written: ${privPath} ✓`);
266
+ }
267
+ // Seed agent via remote ops API
268
+ console.log(`Seeding agent '${agentId}' on ${baseUrl}...`);
269
+ await seedAgentViaOpsApi(opsUrl, agentId, pubKeyB64url, adminUser, flairAdminPass);
270
+ console.log(`Agent '${agentId}' registered on remote instance ✓`);
271
+ }
272
+ else {
273
+ // No agentId -- generate throwaway keypair for FederationInstance row
274
+ console.log("Generating federation instance keypair...");
275
+ const kp = nacl.sign.keyPair();
276
+ pubKeyB64url = b64url(kp.publicKey);
277
+ }
278
+ }
279
+ else {
280
+ console.log("No --agent-id provided -- skipping agent registration");
281
+ }
282
+ // Write FederationInstance row if --remote (hub role)
283
+ if (role) {
284
+ if (!pubKeyB64url) {
285
+ const kp = nacl.sign.keyPair();
286
+ pubKeyB64url = b64url(kp.publicKey);
287
+ }
288
+ instanceId = randomUUID();
289
+ console.log(`Writing federation Instance (role=${role}) via ops API...`);
290
+ await seedFederationInstanceViaOpsApi(opsUrl, instanceId, pubKeyB64url, role, adminUser, flairAdminPass);
291
+ console.log(`Federation Instance created: ${instanceId} (${role}) ✓`);
292
+ }
293
+ // Verify connectivity
294
+ if (didProvision) {
295
+ // Use /FederationInstance with Basic auth (not /Health which false-401s on Fabric)
296
+ console.log("Verifying remote connectivity...");
297
+ const verifyRes = await fetch(`${baseUrl}/FederationInstance`, {
298
+ headers: { Authorization: auth },
299
+ signal: AbortSignal.timeout(5000),
300
+ });
301
+ if (!verifyRes.ok) {
302
+ const body = await verifyRes.text().catch(() => "");
303
+ console.error(`Remote verification failed (${verifyRes.status}): ${body}`);
304
+ process.exit(1);
305
+ }
306
+ console.log("✓ Hub ready at " + baseUrl);
307
+ }
308
+ else {
309
+ // Existing behavior: /Health check (already-running Flair)
310
+ console.log("Verifying remote connectivity...");
311
+ const verifyRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
312
+ if (!verifyRes.ok) {
313
+ console.error(`Remote health check failed: ${verifyRes.status}`);
314
+ process.exit(1);
315
+ }
316
+ console.log("Remote Flair instance healthy ✓");
317
+ }
318
+ // Print summary
319
+ if (didProvision) {
320
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8"));
321
+ const flavor = pkg.version || "(unknown)";
322
+ const displayTarget = target || opsTarget;
323
+ console.log(`\n✓ Flair hub deployed to ${displayTarget}`);
324
+ console.log(` Component: flair@${flavor}`);
325
+ console.log(` Admin user: ${adminUser} (pass written to ${join(homedir(), ".tps", "secrets", "flair-fabric-hdb")})`);
326
+ if (instanceId)
327
+ console.log(` Instance: ${instanceId} (role=${role})`);
328
+ console.log(` Federation: ready — run \`flair federation token\` to mint a pairing token`);
329
+ }
330
+ else {
331
+ console.log(`\n✅ Remote Flair initialized`);
332
+ if (agentId)
333
+ console.log(` Agent ID: ${agentId}`);
334
+ console.log(` Target: ${baseUrl}`);
335
+ if (agentId)
336
+ console.log(` Private key: ${privPath}`);
337
+ if (role)
338
+ console.log(` Role: ${role}`);
339
+ console.log(`\n Export: FLAIR_URL=${baseUrl}`);
340
+ }
341
+ return;
342
+ }
343
+ // ── Local init (full one-command setup) ──
344
+ const keysDir = opts.keysDir ?? defaultKeysDir();
345
+ const dataDir = opts.dataDir ?? defaultDataDir();
346
+ // "create" mode (flair#914): init ESTABLISHES an instance, so a data
347
+ // directory with no recorded port is a new instance taking the default,
348
+ // not the hard error every other caller gets — otherwise `flair init
349
+ // --data-dir <new>` could never succeed. `dataDir` is resolved first so
350
+ // this is never asked before the instance is known.
351
+ //
352
+ // flair#928: `--port` deliberately carries NO commander default, so a bare
353
+ // `init` reaches the ladder below instead of restating DEFAULT_PORT and
354
+ // renumbering an instance that already serves a custom port. `init` is
355
+ // `flair doctor`'s standing remedy and is recommended in ten places, so the
356
+ // command handed to an operator whose install is already wrong must not be
357
+ // the one that moves their port.
358
+ const httpPort = resolveHttpPort(opts, "create");
359
+ // The already-resolved port is handed to the ops resolver rather than
360
+ // letting it re-resolve — its last rung is `resolveHttpPort(opts) - 1`,
361
+ // which would ask the same question again in "address" mode.
362
+ const opsPort = resolveOpsPort({ ...opts, port: httpPort });
363
+ const opsBindHost = resolveOpsBindHost(opts);
364
+ // Resolve MCP client selection (union of init's auto-wire + the multi-client
365
+ // detection/wiring that the front-door command provides). `--no-mcp` sets
366
+ // opts.mcp === false (commander negates the flag). Validate an explicit
367
+ // --client up front so a typo fails before Harper is touched.
368
+ const clientOpt = opts.client;
369
+ const noMcp = opts.mcp === false;
370
+ const selectedClients = [];
371
+ if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
372
+ const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity", "pi"];
373
+ if (!valid.includes(clientOpt)) {
374
+ console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, pi, all, none`);
375
+ process.exit(1);
376
+ }
377
+ selectedClients.push(clientOpt);
378
+ }
379
+ // Admin password: determine from opts, env, or generate
380
+ // Priority: 1) --admin-pass-file, 2) env vars, 3) reuse existing file, 4) generate new
381
+ let adminPass;
382
+ let passwordSource = "generated";
383
+ let reusedExistingAdminPass = false;
384
+ // Warn if --admin-pass is passed inline (not from env)
385
+ if (shouldShowInlineSecretWarning(opts.adminPass, false, new Set(["--admin-pass"]), "--admin-pass")) {
386
+ console.error("warning: --admin-pass passed inline. Consider --admin-pass-file <path> or FLAIR_ADMIN_PASS env " +
387
+ "to keep secrets out of shell history.");
388
+ }
389
+ // Read from file if provided
390
+ if (opts.adminPassFile) {
391
+ try {
392
+ adminPass = readAdminPassFileSecure(opts.adminPassFile);
393
+ }
394
+ catch (err) {
395
+ console.error(`Error: ${err.message}`);
396
+ process.exit(1);
397
+ }
398
+ passwordSource = "file";
399
+ }
400
+ else if (process.env.FLAIR_ADMIN_PASS) {
401
+ adminPass = process.env.FLAIR_ADMIN_PASS;
402
+ passwordSource = "env";
403
+ }
404
+ else if (process.env.HDB_ADMIN_PASSWORD) {
405
+ adminPass = process.env.HDB_ADMIN_PASSWORD;
406
+ passwordSource = "env";
407
+ }
408
+ else if (opts.adminPass) {
409
+ // Inline admin pass (deprecated)
410
+ adminPass = opts.adminPass;
411
+ // Don't generate - don't write to file
412
+ passwordSource = "env"; // Treat same as env for display purposes
413
+ }
414
+ else {
415
+ const flairDir = join(homedir(), ".flair");
416
+ const adminPassPath = join(flairDir, "admin-pass");
417
+ passwordSource = "generated";
418
+ if (resolveInitAdminPasswordSource(existsSync(adminPassPath)) === "reuse-existing") {
419
+ // flair#827: an admin-pass file already on disk means a PRIOR `flair
420
+ // init` already bootstrapped Harper's admin user with this password.
421
+ // HDB_ADMIN_PASSWORD only seeds a brand-new install — Harper does
422
+ // NOT rotate an existing user's stored password hash from env on
423
+ // every boot. Generating and overwriting the file here would desync
424
+ // it from what Harper actually has persisted, breaking ops-API auth
425
+ // (401 "Login failed") on THIS SAME init run (the agent-seeding call
426
+ // below) without fixing whatever the re-run was meant to fix — e.g.
427
+ // `flair doctor`'s ops-bind finding, whose only prescribed remedy is
428
+ // re-running `flair init`. Re-init must be idempotent here: reuse
429
+ // the existing password so it's always safe to re-run against a
430
+ // working install. Rotating the admin password on purpose is a
431
+ // separate, deliberate operation (see the ops runbook), not a side
432
+ // effect of re-init.
433
+ try {
434
+ adminPass = readAdminPassFileSecure(adminPassPath);
435
+ }
436
+ catch (err) {
437
+ console.error(`Error: ${err.message}`);
438
+ process.exit(1);
439
+ }
440
+ reusedExistingAdminPass = true;
441
+ }
442
+ else {
443
+ // Generate new password and write to file atomically
444
+ adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
445
+ // Atomic write: create temp file in same dir, then rename
446
+ mkdirSync(flairDir, { recursive: true });
447
+ const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
448
+ const finalTempPath = join(tempPath, "admin-pass");
449
+ try {
450
+ writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
451
+ renameSync(finalTempPath, adminPassPath);
452
+ rmSync(tempPath, { recursive: true, force: true });
453
+ }
454
+ catch (err) {
455
+ // Clean up temp dir on failure
456
+ try {
457
+ rmSync(tempPath, { recursive: true, force: true });
458
+ }
459
+ catch { }
460
+ throw err;
461
+ }
462
+ }
463
+ }
464
+ const adminUser = DEFAULT_ADMIN_USER;
465
+ // If we generated (or reused) the password, report where it lives
466
+ if (passwordSource === "generated") {
467
+ const adminPassPath = join(homedir(), ".flair", "admin-pass");
468
+ if (reusedExistingAdminPass) {
469
+ console.log(`Reusing existing admin password from: ${adminPassPath} (flair#827: re-init never rotates it — see the ops runbook to change it deliberately)`);
470
+ }
471
+ else {
472
+ console.log(`Admin password saved to: ${adminPassPath}`);
473
+ }
474
+ }
475
+ // Check Node.js version
476
+ const major = parseInt(process.version.slice(1), 10);
477
+ if (major < 18)
478
+ throw new Error(`Node.js >= 18 required (found ${process.version})`);
479
+ let alreadyRunning = false;
480
+ // <ROOTPATH>/models — resources/embeddings-provider.ts's resolveModelsDir()
481
+ // tier 2 default; an operator override already in the environment wins
482
+ // (tier 1). Scoped above the alreadyRunning branch below (not just inside
483
+ // the fresh-start path) since the launchd plist step needs it too, even
484
+ // when Harper was already running and the fresh-spawn branch was skipped.
485
+ const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
486
+ // flair#763: put the ops-socket directory gate in place BEFORE Harper
487
+ // spawns, so the socket is never reachable during the create→chmod window
488
+ // (the dir gate is the race-free primary control). This also validates
489
+ // FLAIR_SOCKET_GROUP early — a bad group fails fast, before a full boot.
490
+ // The socket doesn't exist yet, so only the parent-dir mode is applied here.
491
+ mkdirSync(dataDir, { recursive: true });
492
+ readyOpsSocketPosture(dataDir);
493
+ if (!opts.skipStart) {
494
+ // Check if already running
495
+ try {
496
+ const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
497
+ if (res.status > 0) {
498
+ alreadyRunning = true;
499
+ console.log(`Harper already running on port ${httpPort} — skipping start`);
500
+ }
501
+ }
502
+ catch { /* not running */ }
503
+ if (!alreadyRunning) {
504
+ const bin = harperBin();
505
+ if (!bin) {
506
+ throw new Error("Harper CLI not found: no dist/bin/harper.js under node_modules/harper " +
507
+ "(or the legacy node_modules/@harperfast/harper) next to this flair " +
508
+ `install or in ${process.cwd()}.\n` +
509
+ "Flair ships Harper as a dependency, so this normally means a partial " +
510
+ "or interrupted install.\nFix: reinstall flair — npm install -g @tpsdev-ai/flair");
511
+ }
512
+ mkdirSync(dataDir, { recursive: true });
513
+ // Detect whether Harper has already been installed in this data dir.
514
+ // Harper's config is created during install — its presence means
515
+ // install already ran. Re-running install against an existing data dir
516
+ // crashes in Harper v5 beta.6+ (checkForExistingInstall queries the
517
+ // database before the env is initialized). Goes through
518
+ // harperConfigPath so an install predating Harper's config-file rename
519
+ // (harperdb-config.yaml) is still recognised as installed rather than
520
+ // re-installed over.
521
+ const alreadyInstalled = harperConfigPath(dataDir) !== null;
522
+ const opsSocket = join(dataDir, "operations-server");
523
+ // authorizeLocal: false (flair#654) — a credential-less loopback ops-API
524
+ // request is no longer auto-authorized as super_user. Every ops-API
525
+ // seed call below (seedAgentViaOpsApi et al.) already passes a real
526
+ // adminPass via Basic auth, so this does not change local-init behavior.
527
+ // operationsApi (flair#670): loopback-only by default (buildOperationsApiConfig
528
+ // — see its doc comment for the "host:port" bind mechanism and the
529
+ // domainSocket schema path), escape hatch via --ops-bind/FLAIR_OPS_BIND.
530
+ const harperSetConfig = JSON.stringify({
531
+ rootPath: dataDir,
532
+ http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
533
+ operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
534
+ mqtt: MQTT_DISABLED_CONFIG,
535
+ localStudio: { enabled: false },
536
+ authentication: { authorizeLocal: false, enableSessions: true },
537
+ });
538
+ const env = {
539
+ ...process.env,
540
+ ROOTPATH: dataDir,
541
+ FLAIR_MODELS_DIR: modelsDir,
542
+ HARPER_SET_CONFIG: harperSetConfig,
543
+ DEFAULTS_MODE: "dev",
544
+ HDB_ADMIN_USERNAME: adminUser,
545
+ HDB_ADMIN_PASSWORD: adminPass,
546
+ THREADS_COUNT: "1",
547
+ NODE_HOSTNAME: "localhost",
548
+ HTTP_PORT: String(httpPort),
549
+ // flair#863: host-qualified, NOT a bare port. A bare value here is
550
+ // what Harper latches as `originalValues["operationsApi.network.port"]`
551
+ // when HARPER_SET_CONFIG force-sets the same key — and restores on the
552
+ // first later boot without HARPER_SET_CONFIG, re-widening the bind to
553
+ // all interfaces. See opsNetworkPortValue's doc comment.
554
+ OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opsBindHost, opsPort),
555
+ LOCAL_STUDIO: "false",
556
+ // flair#1586: same MQTT_* re-assert as buildDirectSpawnEnv / the
557
+ // launchd plist, so init cannot restore Harper's 1883/8883 defaults
558
+ // on a later boot that omits HARPER_SET_CONFIG.
559
+ MQTT_NETWORK_PORT: "null",
560
+ MQTT_NETWORK_SECUREPORT: "null",
561
+ MQTT_WEBSOCKET: "false",
562
+ };
563
+ // models (flair#504 Phase 1): the embedding backend registers itself
564
+ // in-process at boot (resources/embeddings-boot.ts, loaded by
565
+ // config.yaml's `jsResource` glob) — NOT via a config env var. See
566
+ // that file's header for why (flair#694: HARPER_CONFIG persisted a
567
+ // `models.embedding.default` block into harper-config.yaml that an
568
+ // older/downgraded build's boot would tear down to an invalid empty
569
+ // shell). FLAIR_MODELS_DIR above is still the channel that tells the
570
+ // registration where to find/download the model.
571
+ if (alreadyInstalled) {
572
+ console.log("Existing Harper installation found — skipping install.");
573
+ console.log("If something is wrong, run: flair doctor");
574
+ }
575
+ else {
576
+ // Isolate install from any global Harper boot file.
577
+ // ~/.harperdb/hdb_boot_properties.file from an unrelated install
578
+ // causes checkForExistingInstall to crash in Harper v5 beta.6+.
579
+ // Only applied to install — run needs real HOME for npm/node resolution.
580
+ const installEnv = { ...env, HOME: join(dataDir, "..") };
581
+ console.log("Installing Harper...");
582
+ console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
583
+ await new Promise((resolve, reject) => {
584
+ let output = "";
585
+ let dotTimer = null;
586
+ const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
587
+ // Print progress dots so the terminal doesn't appear frozen during model download
588
+ dotTimer = setInterval(() => process.stdout.write("."), 3000);
589
+ install.stdout?.on("data", (d) => { output += d.toString(); });
590
+ install.stderr?.on("data", (d) => { output += d.toString(); });
591
+ install.on("exit", (code) => {
592
+ if (dotTimer) {
593
+ clearInterval(dotTimer);
594
+ process.stdout.write("\n");
595
+ }
596
+ code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
597
+ });
598
+ install.on("error", (err) => {
599
+ if (dotTimer) {
600
+ clearInterval(dotTimer);
601
+ process.stdout.write("\n");
602
+ }
603
+ reject(err);
604
+ });
605
+ setTimeout(() => {
606
+ install.kill();
607
+ if (dotTimer) {
608
+ clearInterval(dotTimer);
609
+ process.stdout.write("\n");
610
+ }
611
+ reject(new Error(`Harper install timed out: ${output}`));
612
+ }, 60_000);
613
+ });
614
+ }
615
+ // Start Harper with flair loaded as a component (the "." arg).
616
+ // ROOTPATH in env points to the data dir; authorizeLocal and thread
617
+ // count are set via HARPER_SET_CONFIG — no need for dev mode.
618
+ console.log(`Starting Harper on port ${httpPort}...`);
619
+ const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
620
+ proc.unref();
621
+ // flair#1454: write the identity sidecar immediately after spawn so
622
+ // `flair stop` and `flair status` can classify this daemon's state
623
+ // without lsof. Same call as startFlairProcess() uses.
624
+ if (proc.pid)
625
+ writeDaemonSidecar(dataDir, proc.pid, httpPort);
626
+ }
627
+ console.log("Waiting for Harper health check...");
628
+ await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
629
+ console.log("Harper is healthy ✓");
630
+ // flair#763: the socket now exists — apply its file mode (+ chgrp for the
631
+ // FLAIR_SOCKET_GROUP opt-in). The dir gate above is re-asserted idempotently.
632
+ readyOpsSocketPosture(dataDir);
633
+ // Register launchd service on macOS so Harper survives reboots
634
+ // and `flair restart` / `flair stop` work via launchctl.
635
+ if (process.platform === "darwin") {
636
+ const harperBinPath = harperBin();
637
+ if (harperBinPath) {
638
+ const label = launchdLabel(dataDir);
639
+ const plistDir = defaultLaunchAgentsDir();
640
+ mkdirSync(plistDir, { recursive: true });
641
+ const plistPath = launchdPlistPath(label, plistDir);
642
+ // flair#693 + flair#966: a pre-flair#693 install registered under
643
+ // the bare LEGACY_LAUNCHD_LABEL. init always writes fresh plist
644
+ // content below (it has the current ports/creds in hand), so
645
+ // migration here is just "clean up the old registration" —
646
+ // unload + remove it BEFORE writing the new one, so re-running
647
+ // init never leaves two services behind for this data dir.
648
+ //
649
+ // flair#966: the legacy plist is NOT scoped to this data dir —
650
+ // it is a single global label. cleanupLegacyLaunchdPlist reads
651
+ // ROOTPATH to establish ownership before touching it.
652
+ cleanupLegacyLaunchdPlist(dataDir, plistDir, (cmd) => {
653
+ execSync(cmd, { stdio: "pipe" });
654
+ });
655
+ const opsSocket = join(dataDir, "operations-server");
656
+ // authorizeLocal: false (flair#654) — same posture as the initial spawn
657
+ // above; the launchd-managed process must not diverge from it.
658
+ // operationsApi (flair#670): same buildOperationsApiConfig posture as the
659
+ // initial spawn above — the launchd-managed process must not diverge.
660
+ const setConfig = JSON.stringify({
661
+ rootPath: dataDir,
662
+ http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
663
+ operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
664
+ mqtt: MQTT_DISABLED_CONFIG,
665
+ localStudio: { enabled: false },
666
+ authentication: { authorizeLocal: false, enableSessions: true },
667
+ });
668
+ // models (flair#504 Phase 1): no env var needed here — the
669
+ // launchd-managed process loads the SAME dist/resources/*.js as any
670
+ // other spawn, so resources/embeddings-boot.ts self-registers the
671
+ // backend on every KeepAlive restart in-process. See that file's
672
+ // header (flair#694) for why this replaced the old HARPER_CONFIG
673
+ // plist line.
674
+ const plist = buildLaunchdPlist({
675
+ label,
676
+ execPath: process.execPath,
677
+ harperBinPath,
678
+ workingDirectory: flairPackageDir(),
679
+ dataDir,
680
+ modelsDir,
681
+ setConfig,
682
+ adminUser,
683
+ adminPass,
684
+ httpPort,
685
+ opsNetworkPort: opsNetworkPortValue(opsBindHost, opsPort),
686
+ });
687
+ writeFileSync(plistPath, plist);
688
+ console.log("Launchd service registered ✓");
689
+ }
690
+ }
691
+ }
692
+ // Persist the instance coordinates so other commands can find AND
693
+ // re-assert this instance. flair#863: `opsBind` in particular has to be
694
+ // persisted here, not just handed to this run's spawn — `flair start` /
695
+ // `flair restart` / `flair upgrade` re-assert the bind on every spawn and
696
+ // have no `--ops-bind` flag of their own, so this is the only thing that
697
+ // survives an `--ops-bind` choice past the next restart. It also runs on
698
+ // the already-running path (where init skips the spawn entirely), which is
699
+ // what makes doctor's `flair init && flair restart` remedy actually apply.
700
+ // flair#914: written ONLY when this init is about the default install, so a
701
+ // second instance can no longer overwrite the first's recorded port. This
702
+ // instance's own port needs no write here — Harper has just recorded it in
703
+ // <dataDir>/harper-config.yaml, which is what resolveHttpPort reads (see
704
+ // persistDefaultInstallCoordinates).
705
+ persistDefaultInstallCoordinates(dataDir, httpPort, opsPort, opsBindHost);
706
+ if (agentId) {
707
+ // Generate or reuse keypair
708
+ mkdirSync(keysDir, { recursive: true });
709
+ const privPath = privKeyPath(agentId, keysDir);
710
+ const pubPath = pubKeyPath(agentId, keysDir);
711
+ let pubKeyB64url;
712
+ if (existsSync(privPath)) {
713
+ console.log(`Reusing existing key: ${privPath}`);
714
+ const seed = new Uint8Array(readFileSync(privPath));
715
+ const kp = nacl.sign.keyPair.fromSeed(seed);
716
+ pubKeyB64url = b64url(kp.publicKey);
717
+ }
718
+ else {
719
+ console.log("Generating Ed25519 keypair...");
720
+ const kp = nacl.sign.keyPair();
721
+ // Store only the 32-byte seed (first 32 bytes of secretKey)
722
+ const seed = kp.secretKey.slice(0, 32);
723
+ writeFileSync(privPath, Buffer.from(seed));
724
+ chmodSync(privPath, 0o600);
725
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
726
+ pubKeyB64url = b64url(kp.publicKey);
727
+ console.log(`Keypair written: ${privPath} ✓`);
728
+ }
729
+ // Seed agent via operations API
730
+ console.log(`Seeding agent '${agentId}' via operations API...`);
731
+ await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass);
732
+ console.log(`Agent '${agentId}' registered ✓`);
733
+ // Verify Ed25519 auth
734
+ console.log("Verifying Ed25519 auth...");
735
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
736
+ const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
737
+ if (!verifyRes.ok)
738
+ throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
739
+ console.log("Ed25519 auth verified ✓");
740
+ // Verify semantic search ACTUALLY works (real embed→paraphrase-search
741
+ // round-trip). A clean-VM dogfood found semantic search dead out of the box
742
+ // (sudo/root-owned install can't write the embeddings models symlink →
743
+ // EACCES) while init reported success. Never report a clean init when
744
+ // recall-by-meaning is broken. Skipped paths (no key yet) are non-fatal.
745
+ console.log("Verifying semantic search...");
746
+ const embedCheck = await verifySemanticSearch(httpUrl, agentId, keysDir);
747
+ if (embedCheck.state === "ok") {
748
+ console.log(`Semantic search operational ✓ ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${embedCheck.score.toFixed(2)})`)}`);
749
+ }
750
+ else if (embedCheck.state === "degraded") {
751
+ // LOUD — embeddings not loaded. Same message class as `flair doctor`.
752
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Semantic search DEGRADED")} — embeddings not loaded; recall-by-meaning will NOT work.`);
753
+ console.log(` ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
754
+ console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
755
+ console.log(` ${render.wrap(render.c.dim, "Fix: install without sudo (see README Quick Start), then:")} flair restart && flair doctor`);
756
+ }
757
+ else if (embedCheck.state === "failed") {
758
+ // flair#1501: the instance rejected the probe's signature. init just
759
+ // registered this agent, so this is a genuine auth defect, not a
760
+ // missing identity — surface it loudly with the signer named.
761
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Semantic search probe rejected")} — ${embedCheck.detail}.`);
762
+ console.log(` ${render.wrap(render.c.dim, "Fix: register this key on the instance (`flair agent add <id>`) or pass --agent <a registered agent id>.")}`);
763
+ }
764
+ else {
765
+ console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
766
+ }
767
+ // Verify the audit log ACTUALLY records (flair#970) — a positive
768
+ // control, not a flag read: `describe_table` reports `audit: true` on
769
+ // nodes whose audit trail is empty (base-copy elision, harper#2212).
770
+ // Same surface as the semantic-search check above.
771
+ console.log("Verifying audit log...");
772
+ const auditCheck = await verifyAuditLog(httpUrl, agentId, keysDir, `http://127.0.0.1:${opsPort}`, adminUser, adminPass);
773
+ if (auditCheck.state === "ok") {
774
+ // Present tense ONLY: the probe proves current recording, never
775
+ // historical completeness — see AuditVerifyResult's doc comment.
776
+ console.log(`Audit log: recording (verified now) ✓ ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
777
+ }
778
+ else if (auditCheck.state === "degraded") {
779
+ if (auditCheck.cause === "disabled") {
780
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log DISABLED")} — ${auditCheck.detail}.`);
781
+ console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
782
+ }
783
+ else {
784
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log NOT RECORDING")} — ${auditCheck.detail}.`);
785
+ console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
786
+ console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
787
+ console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
788
+ }
789
+ }
790
+ else if (auditCheck.state === "failed") {
791
+ // Same loud discipline as the semantic-search probe above (flair#1501).
792
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log probe rejected")} — ${auditCheck.detail}.`);
793
+ console.log(` ${render.wrap(render.c.dim, "Fix: register this key on the instance (`flair agent add <id>`) or pass --agent <a registered agent id>.")}`);
794
+ }
795
+ else {
796
+ // An unrun check must not look like a pass.
797
+ console.log(`${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditCheck.detail})`);
798
+ }
799
+ // Output — admin password printed once, never written to disk
800
+ console.log("\n✅ Flair initialized successfully");
801
+ console.log(` Agent ID: ${agentId}`);
802
+ console.log(` Flair URL: ${httpUrl}`);
803
+ console.log(` Private key: ${privPath}`);
804
+ // Display admin credentials when password was generated or from a file
805
+ // Do NOT display when from env (to avoid showing the env var value)
806
+ if (passwordSource !== "env" && !alreadyRunning) {
807
+ const passDisplay = passwordSource === "file"
808
+ ? opts.adminPassFile ?? "(file path)"
809
+ : "~/.flair/admin-pass";
810
+ console.log(`\n ┌─────────────────────────────────────────────────┐`);
811
+ console.log(` │ Harper admin credentials (save these now): │`);
812
+ console.log(` │ │`);
813
+ console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
814
+ console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
815
+ console.log(` │ │`);
816
+ console.log(` │ ⚠️ The password won't be shown again. │`);
817
+ console.log(` └─────────────────────────────────────────────────┘`);
818
+ }
819
+ console.log(`\n Export: FLAIR_URL=${httpUrl}`);
820
+ // ── First-run soul setup ──────────────────────────────────────────────
821
+ // Interactive wizard to set initial personality (see runSoulWizard).
822
+ // Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
823
+ //
824
+ // Non-TTY / --skip-soul used to seed placeholder text like
825
+ // "AI assistant [default]" — it leaked into bootstrap output and
826
+ // confused users. Now those paths leave the soul empty and nudge the
827
+ // user toward `flair soul set` / `flair doctor` instead.
828
+ if (!opts.skipSoul && process.stdin.isTTY) {
829
+ const soulEntries = await runSoulWizard(agentId);
830
+ if (soulEntries.length > 0) {
831
+ console.log("");
832
+ for (const [key, value] of soulEntries) {
833
+ try {
834
+ await api("PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() }, { baseUrl: httpUrl, explicitAdminPass: adminPass, adminUser });
835
+ console.log(` ✓ soul:${key} set`);
836
+ }
837
+ catch (err) {
838
+ const message = err instanceof Error ? err.message : String(err);
839
+ console.warn(` ⚠ soul:${key} failed: ${message}`);
840
+ }
841
+ }
842
+ console.log(`\n ${soulEntries.length} soul entries saved.`);
843
+ console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
844
+ }
845
+ else {
846
+ console.log(`\n No soul entries saved. Add later with:`);
847
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
848
+ console.log(` Or run \`flair doctor\` anytime for a nudge.`);
849
+ }
850
+ }
851
+ else {
852
+ const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
853
+ console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
854
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
855
+ }
856
+ // ── MCP client wiring ────────────────────────────────────────────────
857
+ // The full one-command front door: detect installed MCP clients and wire
858
+ // each to the zero-install `npx -y @tpsdev-ai/flair-mcp@<version>` server
859
+ // (pinned — see mcpServerSpec()). Claude
860
+ // Code is auto-wired into ~/.claude.json (the only client the CLI can
861
+ // safely modify); other clients get copy-paste snippets. `--no-mcp`
862
+ // skips wiring entirely; `--client <name>` targets one client; the
863
+ // default (no flag) wires every detected client.
864
+ const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
865
+ // `wired` is the load-bearing field (flair#906): it separates "a config
866
+ // file was actually written" from "we printed something and moved on".
867
+ // Both outcomes used to be pushed here indistinguishably as far as the
868
+ // user was concerned, so `--client all` reported success for a client it
869
+ // had not wired.
870
+ const wiringResults = [];
871
+ // Human-readable labels + the clients `--client all` passed over because
872
+ // they aren't installed, so the closing summary can account for every
873
+ // client the user asked for rather than only the ones we tried.
874
+ const clientLabels = new Map();
875
+ const skippedUndetected = [];
876
+ if (!noMcp && clientOpt !== "none") {
877
+ // A spec we cannot pin is a security property quietly downgraded, so
878
+ // say so BEFORE writing it and again in the summary below (flair#907).
879
+ // stderr: this must survive `flair init | tee`, and it is a warning,
880
+ // not part of the command's normal output.
881
+ const pinWarning = unpinnedSpecWarning();
882
+ if (pinWarning) {
883
+ console.error("");
884
+ for (const line of pinWarning.split("\n"))
885
+ console.error(` ⚠ ${line}`);
886
+ }
887
+ // Determine which clients to wire.
888
+ let clients = detectClients();
889
+ if (selectedClients.length > 0) {
890
+ clients = clients.filter(c => selectedClients.includes(c.id));
891
+ }
892
+ for (const c of clients)
893
+ clientLabels.set(c.id, c.label);
894
+ const detected = clients.filter(c => c.detected);
895
+ if (!clientOpt) {
896
+ if (detected.length === 0) {
897
+ console.log("\n No MCP clients detected. Run with --client <name> to wire a specific client.");
898
+ }
899
+ else {
900
+ console.log(`\n Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
901
+ }
902
+ }
903
+ const toWire = clientOpt === "all"
904
+ ? clients.filter(c => c.detected).map(c => c.id)
905
+ : selectedClients.length > 0
906
+ ? selectedClients
907
+ : clients.filter(c => c.detected).map(c => c.id);
908
+ // `--client all` is a promise about every client, so the ones it
909
+ // passed over have to be accounted for too — silently omitting them
910
+ // is how "all" reports success for work it never did (flair#906).
911
+ if (clientOpt === "all") {
912
+ for (const c of clients) {
913
+ if (!c.detected)
914
+ skippedUndetected.push(c.label);
915
+ }
916
+ }
917
+ for (const clientId of toWire) {
918
+ if (clientId === "claude-code") {
919
+ // Claude Code gets real auto-wiring into ~/.claude.json (zero-install
920
+ // npx form; matches the snippets everywhere else). Other clients only
921
+ // get printed instructions — the CLI can't safely edit their configs.
922
+ const claudeJsonPath = join(homedir(), ".claude.json");
923
+ const flairMcpConfig = {
924
+ type: "stdio",
925
+ command: "npx",
926
+ args: ["-y", mcpServerSpec()],
927
+ // flair#718 authorship-provenance: each client's wired env block
928
+ // gets its OWN FLAIR_CLIENT label (never the shared mcpEnv
929
+ // object directly — that would stamp the same label into every
930
+ // client's config) so writes from THIS client's proxy stamp
931
+ // provenance.claimed.client = "claude-code".
932
+ env: { ...mcpEnv, FLAIR_CLIENT: "claude-code" },
933
+ };
934
+ // ~/.claude.json exists once Claude Code has been RUN, not once it
935
+ // is installed — so gating the write on it skipped every user who
936
+ // installed Claude Code and Flair in the same sitting (flair#906).
937
+ // The file is Claude Code's own and creating it with a single
938
+ // `mcpServers` key is exactly what `claude mcp add` does, so an
939
+ // absent file is created rather than turned into a printed snippet
940
+ // the user has to notice and act on.
941
+ try {
942
+ const claudeJsonExisted = existsSync(claudeJsonPath);
943
+ const claudeJson = claudeJsonExisted
944
+ ? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
945
+ : {};
946
+ const existing = claudeJson.mcpServers?.flair;
947
+ const currentSpec = mcpServerSpec();
948
+ const existingArgs = existing?.args;
949
+ const argsMatch = Array.isArray(existingArgs) && existingArgs.includes(currentSpec);
950
+ const urlAgentMatch = existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId;
951
+ // flair#1135: the pin in `args` must match the current mcpServerSpec().
952
+ // A matching pin stays a no-op (idempotent); only a stale pin triggers a re-write.
953
+ if (urlAgentMatch && argsMatch) {
954
+ console.log(` ✓ Claude Code already wired in ~/.claude.json`);
955
+ wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
956
+ }
957
+ else {
958
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
959
+ claudeJson.mcpServers.flair = flairMcpConfig;
960
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
961
+ const action = urlAgentMatch ? "refreshed pin in ~/.claude.json"
962
+ : claudeJsonExisted ? "wired in ~/.claude.json"
963
+ : "wired in ~/.claude.json (created)";
964
+ console.log(` ✓ Claude Code ${action} (restart Claude Code to pick it up)`);
965
+ wiringResults.push({
966
+ client: "claude-code",
967
+ message: urlAgentMatch ? "refreshed pin in ~/.claude.json"
968
+ : claudeJsonExisted ? "wired ~/.claude.json"
969
+ : "created and wired ~/.claude.json",
970
+ wired: true,
971
+ });
972
+ }
973
+ }
974
+ catch (err) {
975
+ // Only a genuine read/parse/write failure lands here now (bad
976
+ // permissions, malformed existing JSON) — never merely "the file
977
+ // does not exist yet".
978
+ const reason = err instanceof Error ? err.message : String(err);
979
+ console.log(` ${render.icons.warn} Claude Code: could not write ~/.claude.json (${reason})`);
980
+ console.log(` MCP config (add manually to ~/.claude.json):`);
981
+ console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
982
+ wiringResults.push({ client: "claude-code", message: `snippet printed (${reason})`, wired: false });
983
+ }
984
+ // ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
985
+ // The MCP block alone isn't a working setup — Claude Code also needs
986
+ // the bootstrap instruction in CLAUDE.md, or it never calls
987
+ // mcp__flair__bootstrap and memory silently does nothing. Applied
988
+ // automatically here (same "just do it" shape as the MCP block
989
+ // above); --skip-claude-md opts out and prints the exact line to
990
+ // add by hand instead.
991
+ const claudeMdResult = applyOrReportClaudeMdBootstrap(process.cwd(), homedir(), !!opts.skipClaudeMd);
992
+ console.log(` ${claudeMdResult.ok ? "✓" : "•"} ${claudeMdResult.message}`);
993
+ if (claudeMdResult.hint) {
994
+ for (const line of claudeMdResult.hint.split("\n"))
995
+ console.log(` ${line}`);
996
+ }
997
+ // ── SessionStart hook (flair#597) ─────────────────────────────────
998
+ // Auto-recall on session start needs this hook wired into
999
+ // ~/.claude/settings.json — without it, mcp__flair__bootstrap only
1000
+ // ever runs if the agent remembers to call it itself.
1001
+ // --skip-hook opts out and prints the exact JSON to add by hand.
1002
+ const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook);
1003
+ console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
1004
+ if (hookResult.hint) {
1005
+ for (const line of hookResult.hint.split("\n"))
1006
+ console.log(` ${line}`);
1007
+ }
1008
+ }
1009
+ else {
1010
+ let result;
1011
+ // flair#718 authorship-provenance — see the claude-code branch's
1012
+ // identical comment above: FLAIR_CLIENT is per-client, so it's
1013
+ // added at each call site rather than baked into the shared mcpEnv.
1014
+ switch (clientId) {
1015
+ case "codex":
1016
+ result = wireCodex({ ...mcpEnv, FLAIR_CLIENT: "codex" });
1017
+ break;
1018
+ case "gemini":
1019
+ result = wireGemini({ ...mcpEnv, FLAIR_CLIENT: "gemini" });
1020
+ break;
1021
+ case "cursor":
1022
+ result = wireCursor({ ...mcpEnv, FLAIR_CLIENT: "cursor" });
1023
+ break;
1024
+ case "antigravity":
1025
+ result = wireAntigravity({ ...mcpEnv, FLAIR_CLIENT: "antigravity" });
1026
+ break;
1027
+ // pi is a NATIVE EXTENSION, not an MCP client (flair#1342):
1028
+ // wirePi edits ~/.pi/agent/settings.json `packages`, and pi
1029
+ // settings carry no env block — no FLAIR_CLIENT to stamp; the
1030
+ // wire message tells the user what to export at pi launch.
1031
+ case "pi":
1032
+ result = wirePi(mcpEnv);
1033
+ break;
1034
+ default: result = { ok: false, message: `Unknown client: ${clientId}` };
1035
+ }
1036
+ wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
1037
+ console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
1038
+ // Codex SessionStart hook (flair#1148 / #1439) — the hook is not
1039
+ // optional on Codex (no CLAUDE.md alternative). Init is the
1040
+ // consent to set up the client, same as the Claude Code hook
1041
+ // applied above. --skip-hook opts out and prints the JSON.
1042
+ if (clientId === "codex" && result.ok) {
1043
+ const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook, hookSettingsPath(homedir(), "codex"));
1044
+ console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
1045
+ if (hookResult.hint) {
1046
+ for (const line of hookResult.hint.split("\n"))
1047
+ console.log(` ${line}`);
1048
+ }
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+ // ── Smoke-test the MCP server ────────────────────────────────────────
1054
+ // Launch flair-mcp and confirm it answers a JSON-RPC initialize over
1055
+ // stdio. Best-effort: failures warn but never fail the command. Skipped
1056
+ // with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
1057
+ // pi doesn't run flair-mcp (native extension, flair#1342), so a pi-only
1058
+ // wiring has nothing this smoke test exercises — spawning it anyway
1059
+ // would render a green "MCP server responded" for a setup that never
1060
+ // starts an MCP server.
1061
+ const wiredAnyMcpClient = wiringResults.some((r) => r.client !== "pi");
1062
+ if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0 && wiredAnyMcpClient) {
1063
+ console.log("\n Smoke-testing MCP server...");
1064
+ try {
1065
+ // Same spec that gets WIRED above — the smoke test must exercise the
1066
+ // exact version the user will run, not whatever npm resolves latest to.
1067
+ const mcpProc = spawn("npx", ["-y", mcpServerSpec()], {
1068
+ env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
1069
+ stdio: ["pipe", "pipe", "pipe"],
1070
+ });
1071
+ const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-init", version: "1.0.0" } } });
1072
+ mcpProc.stdin.write(initMsg + "\n");
1073
+ mcpProc.stdin.end();
1074
+ let stdout = "";
1075
+ mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
1076
+ // A single timer drives the timeout AND cleanup. It MUST be cleared on
1077
+ // settle — an un-cleared setTimeout is a live handle that keeps Node's
1078
+ // event loop alive (the ~60s phantom hang after `flair init` printed
1079
+ // success: the smoke timer + the lingering npx child both pinned the
1080
+ // loop). We clear it on every exit path below.
1081
+ let smokeTimer = null;
1082
+ await new Promise((resolve, reject) => {
1083
+ const settle = (fn) => {
1084
+ if (smokeTimer) {
1085
+ clearTimeout(smokeTimer);
1086
+ smokeTimer = null;
1087
+ }
1088
+ fn();
1089
+ };
1090
+ mcpProc.on("exit", (code) => {
1091
+ settle(() => {
1092
+ if (code === 0 && stdout.length > 0)
1093
+ resolve();
1094
+ else
1095
+ reject(new Error(`MCP server exited with code ${code}`));
1096
+ });
1097
+ });
1098
+ mcpProc.on("error", (err) => settle(() => reject(err)));
1099
+ smokeTimer = setTimeout(() => settle(() => { mcpProc.kill("SIGKILL"); reject(new Error("MCP smoke test timed out")); }), 15_000);
1100
+ });
1101
+ try {
1102
+ const lines = stdout.split("\n").filter(l => l.trim());
1103
+ for (const line of lines) {
1104
+ const parsed = JSON.parse(line);
1105
+ if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
1106
+ console.log(" ✓ MCP server responded");
1107
+ break;
1108
+ }
1109
+ }
1110
+ }
1111
+ catch {
1112
+ console.log(" ⚠ MCP server responded but response could not be parsed");
1113
+ }
1114
+ finally {
1115
+ // Reap the child even on the resolve path: the MCP server exits on
1116
+ // stdin close, but the `npx` wrapper can linger holding the loop.
1117
+ // SIGKILL is safe — we already have the response we need.
1118
+ try {
1119
+ if (mcpProc.exitCode === null)
1120
+ mcpProc.kill("SIGKILL");
1121
+ }
1122
+ catch { /* already gone */ }
1123
+ }
1124
+ }
1125
+ catch (err) {
1126
+ const message = err instanceof Error ? err.message : String(err);
1127
+ console.log(` ⚠ MCP smoke test failed: ${message}`);
1128
+ console.log(" Use --skip-smoke to bypass.");
1129
+ }
1130
+ }
1131
+ // ── MCP wiring summary (flair#906) ───────────────────────────────────
1132
+ // LAST thing init prints, deliberately. Every fact below was already
1133
+ // available mid-run, but a client that was not wired appeared only as a
1134
+ // snippet in the middle of a wall of output, after a success line — so
1135
+ // the user's next action was to open their client and find nothing
1136
+ // there, with no reason to suspect init. A client the user asked for and
1137
+ // did NOT get must still be on screen when the command finishes.
1138
+ if (!noMcp && clientOpt !== "none") {
1139
+ // The pin warning repeats here for the same reason the summary exists:
1140
+ // a warning only in scrollback is a warning the user acts on never.
1141
+ const summaryLines = renderWiringSummary(wiringResults, {
1142
+ labels: clientLabels,
1143
+ skippedUndetected,
1144
+ rewireHint: `flair init --agent ${agentId} --client all`,
1145
+ unpinned: unpinnedSpecWarning() !== null,
1146
+ });
1147
+ for (const line of summaryLines) {
1148
+ const icon = line.level === "ok" ? render.icons.ok :
1149
+ line.level === "error" ? render.icons.error :
1150
+ line.level === "warn" ? render.icons.warn :
1151
+ line.level === "muted" ? render.icons.bullet :
1152
+ null;
1153
+ if (line.level === "heading")
1154
+ console.log(`\n ${line.text}`);
1155
+ else
1156
+ console.log(` ${icon} ${line.text}`);
1157
+ }
1158
+ }
1159
+ }
1160
+ else {
1161
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
1162
+ console.log("\n✅ Flair initialized (no agent registered)");
1163
+ console.log(` Flair URL: ${httpUrl}`);
1164
+ // Display admin credentials when password was generated or from a file
1165
+ // Do NOT display when from env (to avoid showing the env var value)
1166
+ if (passwordSource !== "env" && !alreadyRunning) {
1167
+ const passDisplay = passwordSource === "file"
1168
+ ? opts.adminPassFile ?? "(file path)"
1169
+ : "~/.flair/admin-pass";
1170
+ console.log(`\n ┌─────────────────────────────────────────────────┐`);
1171
+ console.log(` │ Harper admin credentials (save these now): │`);
1172
+ console.log(` │ │`);
1173
+ console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
1174
+ console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
1175
+ console.log(` │ │`);
1176
+ console.log(` │ ⚠️ The password won't be shown again. │`);
1177
+ console.log(` └─────────────────────────────────────────────────┘`);
1178
+ }
1179
+ console.log(`\n Export: FLAIR_URL=${httpUrl}`);
1180
+ // flair#802a: a non-interactive shell (CI, Docker, an unattended setup
1181
+ // script) that omits --agent lands here with NO indication that agent
1182
+ // registration, MCP client wiring, and the smoke test were all skipped
1183
+ // — the run exits 0 and looks complete. In a real TTY the missing
1184
+ // --agent is usually obvious from the command the user just typed;
1185
+ // non-interactively it's easy to never notice until something that
1186
+ // needed the agent (recall, an MCP client) mysteriously doesn't work.
1187
+ if (!process.stdin.isTTY) {
1188
+ console.log(`\n ℹ Non-interactive shell: skipped agent registration, MCP client wiring, and the smoke test.`);
1189
+ console.log(` Complete setup with: flair init --agent <id> --client all`);
1190
+ }
1191
+ }
1192
+ // All init work is genuinely done at this point: Harper is installed +
1193
+ // running (detached, unref'd — survives this process exiting), the agent is
1194
+ // registered, semantic search is verified, MCP clients are wired, and the
1195
+ // smoke test ran. The MCP smoke subprocess can leave a lingering npx handle
1196
+ // that pins Node's event loop for ~60s after success ("rc=0 but doesn't
1197
+ // return"). We've cleared/unref'd the known timers above; exit explicitly so
1198
+ // the prompt returns in a couple seconds regardless of any stray handle. The
1199
+ // running Harper instance is unaffected.
1200
+ await new Promise((r) => process.stdout.write("", () => r()));
1201
+ process.exit(0);
1202
+ });
1203
+ }