@tpsdev-ai/flair 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import nacl from "tweetnacl";
4
- import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { join, resolve as resolvePath } from "node:path";
4
+ import { load as parseYaml } from "js-yaml";
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
6
+ import { homedir, hostname, tmpdir } from "node:os";
7
+ import { join, resolve, sep } from "node:path";
7
8
  import { spawn } from "node:child_process";
8
- import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
9
+ import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
10
+ import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
9
11
  import { keystore } from "./keystore.js";
10
12
  import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
13
+ import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
11
14
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
12
15
  // src/ into resources/, which don't survive npm packaging (see also
13
16
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -30,6 +33,50 @@ function signBody(body, secretKey) {
30
33
  const sig = nacl.sign.detached(message, secretKey);
31
34
  return Buffer.from(sig).toString("base64url");
32
35
  }
36
+ // ─── Secret detection helpers ────────────────────────
37
+ /**
38
+ * Check if a value looks like a real secret/password/token.
39
+ * Triggers warning when:
40
+ * - length >= 8
41
+ * - contains only alphanumerics and URL-safe punctuation (._-)
42
+ * - NOT a URL (doesn't contain ://)
43
+ */
44
+ function isLikelyRealSecret(value) {
45
+ if (!value || value.length < 8)
46
+ return false;
47
+ if (value.includes("://"))
48
+ return false; // exclude URLs
49
+ // Match typical password/token format: alphanumerics + URL-safe punct
50
+ const pattern = /^[A-Za-z0-9._-]+$/;
51
+ return pattern.test(value);
52
+ }
53
+ /**
54
+ * Determine if we should show an inline-secret warning.
55
+ *
56
+ * @param optValue - The value from the command line option
57
+ * @param fromEnv - Whether the value came from an environment variable (true = no warning)
58
+ * @param secretFlagNames - Set of flag names that carry secrets
59
+ * @param flagName - The flag being checked
60
+ * @returns true if warning should be shown
61
+ */
62
+ function shouldShowInlineSecretWarning(optValue, fromEnv, secretFlagNames, flagName) {
63
+ // Skip if no value provided
64
+ if (!optValue || optValue === "")
65
+ return false;
66
+ // Skip URLs (not secrets)
67
+ if (flagName === "--target" || flagName === "--url")
68
+ return false;
69
+ // Only warn for secret-bearing flags
70
+ if (!secretFlagNames.has(flagName))
71
+ return false;
72
+ // Skip if value came from env (not argv)
73
+ if (fromEnv)
74
+ return false;
75
+ // Check if value looks like a real secret
76
+ if (!isLikelyRealSecret(optValue))
77
+ return false;
78
+ return true;
79
+ }
33
80
  // ─── Defaults ────────────────────────────────────────────────────────────────
34
81
  const DEFAULT_PORT = 19926;
35
82
  const DEFAULT_OPS_PORT = 19925;
@@ -103,6 +150,64 @@ function resolveOpsPort(opts) {
103
150
  // Default: httpPort - 1
104
151
  return resolveHttpPort(opts) - 1;
105
152
  }
153
+ // ─── Target resolution (remote Flair instance) ─────────────────────────────────
154
+ // --target <url> (or FLAIR_TARGET env) points all CLI operations at a remote
155
+ // Flair instance instead of localhost. This enables bootstrapping and
156
+ // managing Fabric-deployed Flair instances.
157
+ function resolveTarget(opts) {
158
+ return opts.target || process.env.FLAIR_TARGET || undefined;
159
+ }
160
+ /** Resolve the ops API target URL from --ops-target flag or FLAIR_OPS_TARGET env.
161
+ * Returns undefined if neither is set (caller should fall back to derivation or localhost).
162
+ */
163
+ function resolveOpsTarget(opts) {
164
+ return opts.opsTarget || process.env.FLAIR_OPS_TARGET || undefined;
165
+ }
166
+ /** Derive the ops API URL from a Flair base URL.
167
+ * Convention: ops port = HTTP port - 1.
168
+ * If target has an explicit port, use port-1 (validated: must be 1-65535).
169
+ * If no explicit port: https → 442 (443-1), http → 19925 (19926-1), bare host → https://<host>:19925.
170
+ * Throws on unparseable URLs.
171
+ */
172
+ /** Compute the effective ops API URL for remote commands.
173
+ * - If --ops-target is set, use it directly (no derivation).
174
+ * - Else if --target is set, derive ops URL via resolveOpsUrlFromTarget.
175
+ * - Else return undefined (fall back to localhost resolution).
176
+ */
177
+ function resolveEffectiveOpsUrl(opts) {
178
+ const opsTarget = resolveOpsTarget(opts);
179
+ if (opsTarget)
180
+ return opsTarget.replace(/\/$/, "");
181
+ const target = resolveTarget(opts);
182
+ if (target)
183
+ return resolveOpsUrlFromTarget(target);
184
+ return undefined;
185
+ }
186
+ function resolveOpsUrlFromTarget(targetUrl) {
187
+ // Normalise bare hosts: add https:// prefix so URL parser can handle them.
188
+ const normalised = targetUrl.includes("://") ? targetUrl : `https://${targetUrl}`;
189
+ const url = new URL(normalised);
190
+ const port = parseInt(url.port, 10);
191
+ if (!isNaN(port) && port > 0 && port <= 65535) {
192
+ const opsPort = port - 1;
193
+ if (opsPort < 1)
194
+ throw new Error(`Derived ops port ${opsPort} is out of range; target port must be > 1`);
195
+ url.port = String(opsPort);
196
+ return url.toString().replace(/\/$/, "");
197
+ }
198
+ // No valid explicit port — reject port 0 or out-of-range
199
+ if (url.port !== "" && url.port !== undefined) {
200
+ throw new Error(`Invalid target port: ${url.port} (must be 1-65535)`);
201
+ }
202
+ // No explicit port — infer from scheme
203
+ if (url.protocol === "https:") {
204
+ url.port = "442";
205
+ }
206
+ else {
207
+ url.port = String(DEFAULT_OPS_PORT);
208
+ }
209
+ return url.toString().replace(/\/$/, "");
210
+ }
106
211
  function writeConfig(port) {
107
212
  const p = configPath();
108
213
  mkdirSync(join(homedir(), ".flair"), { recursive: true });
@@ -136,22 +241,40 @@ function b64(bytes) {
136
241
  function b64url(bytes) {
137
242
  return Buffer.from(bytes).toString("base64url");
138
243
  }
139
- async function api(method, path, body) {
244
+ function isLocalBase(base) {
245
+ try {
246
+ const url = new URL(base);
247
+ return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
248
+ }
249
+ catch {
250
+ return !base;
251
+ }
252
+ }
253
+ async function api(method, path, body, options) {
140
254
  // Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
255
+ // When baseUrl is provided (--target), use it directly.
141
256
  const savedPort = readPortFromConfig();
142
257
  const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
143
- const base = process.env.FLAIR_URL || defaultUrl;
258
+ const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
259
+ const isLocal = isLocalBase(base);
144
260
  // Auth resolution order:
145
261
  // 1. FLAIR_TOKEN env → Bearer token (backward compat)
146
- // 2. FLAIR_AGENT_ID env + key file Ed25519 signature (standard)
147
- // 3. --agent flag extracted from body.agentId + key file Ed25519 signature
148
- // 4. No auth (will 401 on any authenticated endpoint)
262
+ // 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env Basic admin auth (remote targets only).
263
+ // For local targets with authorizeLocal=true, skip Basic auth and let Harper handle it.
264
+ // 3. FLAIR_AGENT_ID env + key file Ed25519 signature (standard)
265
+ // 4. No auth (Harper authorizeLocal handles local; remote will 401)
266
+ //
267
+ // NOTE: this function is for the Harper HTTP/REST API only. The Harper
268
+ // operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
269
+ // does NOT honor authorizeLocal — it always requires Basic admin auth, and
270
+ // those helpers send it unconditionally. authorizeLocal=true affects this
271
+ // path; it does not affect ops-API calls.
149
272
  let authHeader;
150
273
  const token = process.env.FLAIR_TOKEN;
151
274
  if (token) {
152
275
  authHeader = `Bearer ${token}`;
153
276
  }
154
- else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
277
+ else if (!isLocal && (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD)) {
155
278
  // Admin Basic auth — used by federation, backup, and other admin CLI commands
156
279
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
157
280
  authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
@@ -174,7 +297,8 @@ async function api(method, path, body) {
174
297
  }
175
298
  catch (err) {
176
299
  // Key exists but auth build failed — warn and continue without auth
177
- console.error(`Warning: Ed25519 auth failed for agent '${agentId}': ${err.message}`);
300
+ const message = err instanceof Error ? err.message : String(err);
301
+ console.error(`Warning: Ed25519 auth failed for agent '${agentId}': ${message}`);
178
302
  }
179
303
  }
180
304
  }
@@ -303,9 +427,22 @@ function readHarperPid(dataDir) {
303
427
  return null;
304
428
  }
305
429
  }
306
- async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass) {
307
- const url = `http://127.0.0.1:${opsPort}/`;
308
- const auth = Buffer.from(`${adminUser}:${adminPass}`).toString("base64");
430
+ /**
431
+ * Seed an agent record via the Harper operations API.
432
+ * Accepts either a port number (localhost) or a full URL string (--target).
433
+ *
434
+ * `adminPass` is typed as optional but in practice the Harper operations API
435
+ * always requires Basic admin auth — every existing call site passes one.
436
+ * The optional signature leaves headroom for a future Harper that honors
437
+ * `authorizeLocal` on its ops endpoint; until then, callers must pass it.
438
+ */
439
+ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, adminUser, adminPass) {
440
+ const url = typeof opsPortOrUrl === "number"
441
+ ? `http://127.0.0.1:${opsPortOrUrl}/`
442
+ : `${opsPortOrUrl.replace(/\/$/, "")}/`;
443
+ // Send Basic auth whenever the caller passed an adminPass. The caller decides
444
+ // when to omit it (e.g., local target with authorizeLocal=true).
445
+ const auth = adminPass !== undefined ? Buffer.from(`${adminUser}:${adminPass}`).toString("base64") : undefined;
309
446
  const body = {
310
447
  operation: "insert",
311
448
  database: "flair",
@@ -314,8 +451,9 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
314
451
  };
315
452
  const res = await fetch(url, {
316
453
  method: "POST",
317
- headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
454
+ headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
318
455
  body: JSON.stringify(body),
456
+ signal: AbortSignal.timeout(10_000),
319
457
  });
320
458
  if (!res.ok) {
321
459
  const text = await res.text().catch(() => "");
@@ -324,6 +462,301 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
324
462
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
325
463
  }
326
464
  }
465
+ // Seed an agent record via the Harper REST API.
466
+ // Uses localhost and the given HTTP port.
467
+ export async function seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass) {
468
+ const baseUrl = `http://127.0.0.1:${httpPort}`;
469
+ const body = {
470
+ operation: "insert",
471
+ database: "flair",
472
+ table: "Agent",
473
+ records: [{ id: agentId, name: agentId, publicKey: pubKeyB64url, createdAt: new Date().toISOString() }],
474
+ };
475
+ // Only send Authorization header if adminPass is provided.
476
+ // Matches the auth pattern in api() which respects authorizeLocal=true.
477
+ const headers = { "Content-Type": "application/json" };
478
+ if (adminPass) {
479
+ headers.Authorization = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
480
+ }
481
+ const res = await fetch(`${baseUrl}/Agent`, {
482
+ method: "POST",
483
+ headers,
484
+ body: JSON.stringify(body),
485
+ signal: AbortSignal.timeout(10_000),
486
+ });
487
+ if (!res.ok) {
488
+ const text = await res.text().catch(() => "");
489
+ if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
490
+ return;
491
+ throw new Error(`REST API insert failed (${res.status}): ${text}`);
492
+ }
493
+ }
494
+ // ─── FederationInstance seed via ops API ──────────────────────────────────────
495
+ //
496
+ // Remote init writes FederationInstance through the ops API (Basic auth with
497
+ // admin:admin-pass), not the REST API (which needs server-side HDB_ADMIN_PASSWORD
498
+ // — unavailable on Fabric). Same pattern as seedAgentViaOpsApi above.
499
+ //
500
+ // `adminPass` is optional in the signature for symmetry with seedAgentViaOpsApi
501
+ // and to keep the door open for a future Harper that honors authorizeLocal on
502
+ // its ops endpoint. Today the Harper operations API always requires Basic admin
503
+ // auth; every current caller passes it.
504
+ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId, publicKey, role, adminUser, adminPass) {
505
+ const url = typeof opsPortOrUrl === "number"
506
+ ? `http://127.0.0.1:${opsPortOrUrl}/`
507
+ : `${opsPortOrUrl.replace(/\/$/, "")}/`;
508
+ // Send Basic auth whenever the caller passed an adminPass. The caller decides
509
+ // when to omit it (e.g., local target with authorizeLocal=true).
510
+ const auth = adminPass !== undefined ? Buffer.from(`${adminUser}:${adminPass}`).toString("base64") : undefined;
511
+ const now = new Date().toISOString();
512
+ const body = {
513
+ operation: "insert",
514
+ database: "flair",
515
+ table: "Instance",
516
+ records: [{
517
+ id: instanceId,
518
+ publicKey,
519
+ role,
520
+ status: "active",
521
+ createdAt: now,
522
+ updatedAt: now,
523
+ }],
524
+ };
525
+ const res = await fetch(url, {
526
+ method: "POST",
527
+ headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
528
+ body: JSON.stringify(body),
529
+ signal: AbortSignal.timeout(10_000),
530
+ });
531
+ if (!res.ok) {
532
+ const text = await res.text().catch(() => "");
533
+ if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
534
+ return;
535
+ throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
536
+ }
537
+ }
538
+ // ─── Provision Flair on Harper Fabric (ops-2kyi) ───────────────────────────
539
+ //
540
+ // Atomic provisioning for a fresh Harper Fabric cluster: builds a deploy
541
+ // tarball with .env baked in, deploys via ops API, waits for restart, and
542
+ // creates the super_user admin account.
543
+ export async function callOpsApi(opsUrl, body, user, pass) {
544
+ const url = `${opsUrl.replace(/\/$/, "")}/`;
545
+ const auth = Buffer.from(`${user}:${pass}`).toString("base64");
546
+ const res = await fetch(url, {
547
+ method: "POST",
548
+ headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
549
+ body: JSON.stringify(body),
550
+ signal: AbortSignal.timeout(30_000),
551
+ });
552
+ if (!res.ok) {
553
+ const text = await res.text().catch(() => "");
554
+ throw new Error(`Ops API call failed (${res.status}): ${text}`);
555
+ }
556
+ return res.json();
557
+ }
558
+ export async function buildDeployTarball(projectRoot, flairAdminPass) {
559
+ const tmpDir = mkdtempSync(join(tmpdir(), "flair-deploy-"));
560
+ try {
561
+ // Copy deployment files into temp directory
562
+ const entries = ["dist", "schemas", "config.yaml", "package.json", "LICENSE", "README.md", "SECURITY.md"];
563
+ if (existsSync(join(projectRoot, "ui")))
564
+ entries.push("ui");
565
+ for (const entry of entries) {
566
+ const src = join(projectRoot, entry);
567
+ const dst = join(tmpDir, entry);
568
+ if (existsSync(src)) {
569
+ cpSync(src, dst, { recursive: true });
570
+ }
571
+ }
572
+ // Write .env with 600 permissions
573
+ const envContent = [
574
+ `HDB_ADMIN_PASSWORD=${flairAdminPass}`,
575
+ `FLAIR_ADMIN_PASSWORD=${flairAdminPass}`,
576
+ "",
577
+ ].join("\n");
578
+ writeFileSync(join(tmpDir, ".env"), envContent, { mode: 0o600 });
579
+ // Build compressed tarball
580
+ const tarballPath = join(tmpDir, "deploy.tar.gz");
581
+ await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, entries);
582
+ const buf = readFileSync(tarballPath);
583
+ return { tarballB64: buf.toString("base64") };
584
+ }
585
+ finally {
586
+ rmSync(tmpDir, { recursive: true, force: true });
587
+ }
588
+ }
589
+ export async function waitForFlairRestart(targetUrl, maxWaitMs = 30_000) {
590
+ const url = `${targetUrl.replace(/\/$/, "")}/FederationPair`;
591
+ const intervalMs = 1_000;
592
+ const deadline = Date.now() + maxWaitMs;
593
+ while (Date.now() < deadline) {
594
+ try {
595
+ const res = await fetch(url, {
596
+ method: "POST",
597
+ headers: { "Content-Type": "application/json" },
598
+ body: JSON.stringify({}),
599
+ signal: AbortSignal.timeout(5_000),
600
+ });
601
+ const text = await res.text().catch(() => "");
602
+ // The resource handler responds with "instanceId and publicKey required"
603
+ // when the deployment is live and Flair is serving requests.
604
+ if (text.includes("instanceId and publicKey required"))
605
+ return;
606
+ }
607
+ catch {
608
+ // Not ready yet — keep polling
609
+ }
610
+ await new Promise((r) => setTimeout(r, intervalMs));
611
+ }
612
+ throw new Error(`Flair did not respond within ${maxWaitMs / 1000}s`);
613
+ }
614
+ export async function provisionFabric(target, opsTarget, clusterAdminUser, clusterAdminPass, flairAdminPass) {
615
+ const projectRoot = process.cwd();
616
+ // 1. Build and deploy component tarball
617
+ console.log("Building deploy tarball...");
618
+ const { tarballB64 } = await buildDeployTarball(projectRoot, flairAdminPass);
619
+ console.log("Deploying via ops API...");
620
+ await callOpsApi(opsTarget, {
621
+ operation: "deploy_component",
622
+ project: "flair",
623
+ payload: tarballB64,
624
+ restart: "rolling",
625
+ }, clusterAdminUser, clusterAdminPass);
626
+ // 2. Wait for restart
627
+ console.log("Waiting for Flair to restart...");
628
+ await waitForFlairRestart(target);
629
+ console.log("Flair is running ✓");
630
+ // 3. Provision Harper super_user
631
+ // Since ops-lzmg is merged, the username doesn't have to be "admin".
632
+ // We can use the cluster-admin user directly if it's already a super_user.
633
+ // Check if cluster admin is already a super_user first:
634
+ let clusterAdminIsSuperUser = false;
635
+ try {
636
+ const userInfo = await callOpsApi(opsTarget, {
637
+ operation: "list_users",
638
+ }, clusterAdminUser, clusterAdminPass);
639
+ // list_users returns an array of user objects with role/permission info
640
+ const users = Array.isArray(userInfo) ? userInfo : [];
641
+ const adminRecord = users.find((u) => u.username === clusterAdminUser || u.user?.username === clusterAdminUser);
642
+ clusterAdminIsSuperUser = !!(adminRecord?.role?.permission?.super_user ??
643
+ adminRecord?.permission?.super_user ??
644
+ false);
645
+ }
646
+ catch {
647
+ // If we can't check, assume not and proceed with add_user
648
+ }
649
+ if (clusterAdminIsSuperUser) {
650
+ console.log(`Cluster admin '${clusterAdminUser}' is already a super_user — skipping user provisioning`);
651
+ }
652
+ else {
653
+ console.log(`Provisioning Harper user 'admin' as super_user...`);
654
+ try {
655
+ await callOpsApi(opsTarget, {
656
+ operation: "add_user",
657
+ username: "admin",
658
+ password: flairAdminPass,
659
+ role: "super_user",
660
+ active: true,
661
+ }, clusterAdminUser, clusterAdminPass);
662
+ console.log("User 'admin' created ✓");
663
+ }
664
+ catch (err) {
665
+ const msg = err instanceof Error ? err.message : String(err);
666
+ if (msg.includes("already exists") || msg.includes("duplicate")) {
667
+ // Idempotent: fall back to alter_user
668
+ console.log("User 'admin' already exists — updating password...");
669
+ await callOpsApi(opsTarget, {
670
+ operation: "alter_user",
671
+ username: "admin",
672
+ password: flairAdminPass,
673
+ role: "super_user",
674
+ active: true,
675
+ }, clusterAdminUser, clusterAdminPass);
676
+ console.log("User 'admin' updated ✓");
677
+ }
678
+ else {
679
+ throw err;
680
+ }
681
+ }
682
+ }
683
+ }
684
+ // ─── flair_pair_initiator role ──────────────────────────────────────────────
685
+ //
686
+ // Hub instances need a `flair_pair_initiator` role so that bootstrap credentials
687
+ // (created in PR-2) can pass platform auth on Harper Fabric before reaching the
688
+ // FederationPair resource handler. The role carries no table permissions itself
689
+ // — the resource's own allowCreate bypass handles route-level access once the
690
+ // request gets through the auth gate.
691
+ /** Canonical permission spec for flair_pair_initiator. */
692
+ const PAIR_INITIATOR_PERMISSION = {
693
+ super_user: false,
694
+ cluster_user: false,
695
+ structure_user: false,
696
+ flair: {
697
+ tables: {
698
+ Memory: { read: false, insert: false, update: false, delete: false },
699
+ Soul: { read: false, insert: false, update: false, delete: false },
700
+ Agent: { read: false, insert: false, update: false, delete: false },
701
+ Workspace: { read: false, insert: false, update: false, delete: false },
702
+ Event: { read: false, insert: false, update: false, delete: false },
703
+ OAuth: { read: false, insert: false, update: false, delete: false },
704
+ Instance: { read: false, insert: false, update: false, delete: false },
705
+ Peer: { read: false, insert: false, update: false, delete: false },
706
+ PairingToken: { read: false, insert: false, update: false, delete: false },
707
+ SyncLog: { read: false, insert: false, update: false, delete: false },
708
+ },
709
+ },
710
+ };
711
+ /**
712
+ * Idempotently ensures the `flair_pair_initiator` role exists on the Harper
713
+ * instance at `opsUrl` with the canonical permission spec.
714
+ *
715
+ * - If the role is absent → `add_role`
716
+ * - If it exists with different permissions → `alter_role` to bring it into spec
717
+ * - If it already matches → no-op
718
+ */
719
+ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass) {
720
+ const ROLE_NAME = "flair_pair_initiator";
721
+ // 1. Check for existing role
722
+ let roles = [];
723
+ try {
724
+ const result = await callOpsApi(opsUrl, { operation: "list_roles" }, adminUser, adminPass);
725
+ roles = Array.isArray(result) ? result : [];
726
+ }
727
+ catch (err) {
728
+ const msg = err instanceof Error ? err.message : String(err);
729
+ throw new Error(`ensureFlairPairInitiatorRole: list_roles failed: ${msg}`);
730
+ }
731
+ const existing = roles.find((r) => r.role === ROLE_NAME || r.name === ROLE_NAME);
732
+ if (!existing) {
733
+ // 2a. Role absent → create it
734
+ console.log(`Creating role '${ROLE_NAME}'...`);
735
+ await callOpsApi(opsUrl, {
736
+ operation: "add_role",
737
+ role: ROLE_NAME,
738
+ permission: PAIR_INITIATOR_PERMISSION,
739
+ }, adminUser, adminPass);
740
+ console.log(`Role '${ROLE_NAME}' created ✓`);
741
+ return;
742
+ }
743
+ // 2b. Role exists — check if permissions match the canonical spec
744
+ const existingPerm = existing.permission ?? existing.role?.permission;
745
+ const canonicalStr = JSON.stringify(PAIR_INITIATOR_PERMISSION);
746
+ const existingStr = JSON.stringify(existingPerm);
747
+ if (existingStr === canonicalStr) {
748
+ console.log(`Role '${ROLE_NAME}' already exists with correct permissions — skipping`);
749
+ return;
750
+ }
751
+ // 2c. Permissions differ → bring into spec via alter_role
752
+ console.log(`Role '${ROLE_NAME}' exists but permissions differ — updating...`);
753
+ await callOpsApi(opsUrl, {
754
+ operation: "alter_role",
755
+ role: ROLE_NAME,
756
+ permission: PAIR_INITIATOR_PERMISSION,
757
+ }, adminUser, adminPass);
758
+ console.log(`Role '${ROLE_NAME}' updated ✓`);
759
+ }
327
760
  // ─── Upgrade presence probes ──────────────────────────────────────────────────
328
761
  //
329
762
  // `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
@@ -369,6 +802,35 @@ export function probeLibVersion(pkgName) {
369
802
  return null;
370
803
  }
371
804
  }
805
+ /**
806
+ * Read the version of an OpenClaw plugin from `~/.openclaw/extensions/<name>/package.json`.
807
+ *
808
+ * `flair upgrade` uses this to surface the installed `@tpsdev-ai/openclaw-flair`
809
+ * version even though it isn't a globally-installed bin or a flair lib dep.
810
+ * Returns null if openclaw isn't installed, the extension isn't installed, or
811
+ * the package.json can't be parsed.
812
+ *
813
+ * @param extensionName — the directory name under `~/.openclaw/extensions/`
814
+ * (typically the plugin name without scope, e.g. `openclaw-flair`)
815
+ */
816
+ export function probeOpenclawPluginVersion(extensionName) {
817
+ try {
818
+ const { existsSync, readFileSync } = require("node:fs");
819
+ const { homedir } = require("node:os");
820
+ const { resolve } = require("node:path");
821
+ // process.env.HOME first so tests can override; homedir() as fallback —
822
+ // homedir() doesn't honor runtime HOME changes (caches at module load).
823
+ const home = process.env.HOME ?? homedir();
824
+ const pkgJsonPath = resolve(home, ".openclaw", "extensions", extensionName, "package.json");
825
+ if (!existsSync(pkgJsonPath))
826
+ return null;
827
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
828
+ return typeof pkg.version === "string" ? pkg.version : null;
829
+ }
830
+ catch {
831
+ return null;
832
+ }
833
+ }
372
834
  export function templateSoul(choice) {
373
835
  const templates = {
374
836
  "1": [
@@ -515,7 +977,8 @@ async function runSoulWizard(agentId) {
515
977
  }
516
978
  }
517
979
  catch (err) {
518
- console.log(`\n Couldn't parse JSON (${err.message}). Falling back to custom prompts.`);
980
+ const message = err instanceof Error ? err.message : String(err);
981
+ console.log(`\n Couldn't parse JSON (${message}). Falling back to custom prompts.`);
519
982
  entries = await customSoulPrompts(ask);
520
983
  }
521
984
  }
@@ -542,24 +1005,263 @@ program.name("flair").version(__pkgVersion, "-v, --version");
542
1005
  // ─── flair init ──────────────────────────────────────────────────────────────
543
1006
  program
544
1007
  .command("init")
545
- .description("Bootstrap a local Flair (Harper) instance for an agent")
546
- .option("--agent-id <id>", "Agent ID to register", "local")
1008
+ .description("Bootstrap a Flair (Harper) instance for an agent")
1009
+ .option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
547
1010
  .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
548
1011
  .option("--ops-port <port>", "Harper operations API port")
549
1012
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
1013
+ .option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
550
1014
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
551
1015
  .option("--data-dir <dir>", "Harper data directory")
552
1016
  .option("--skip-start", "Skip Harper startup (assume already running)")
553
1017
  .option("--skip-soul", "Skip interactive personality setup")
1018
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
1019
+ .option("--remote", "When used with --target, init as hub for remote federation")
1020
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
1021
+ .option("--force", "Skip confirmation prompt for remote writes (required with --target)")
1022
+ .option("--cluster-admin-user <user>", "Harper cluster admin username (env: FLAIR_CLUSTER_ADMIN_USER)")
1023
+ .option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
1024
+ .option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
554
1025
  .action(async (opts) => {
555
1026
  const agentId = opts.agentId;
1027
+ const target = resolveTarget(opts);
1028
+ const opsTarget = resolveOpsTarget(opts);
1029
+ // ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
1030
+ if (target || opsTarget) {
1031
+ // When -only- --ops-target is provided, attempt to derive REST URL
1032
+ if (!target && opsTarget) {
1033
+ console.error("Error: --ops-target requires --target as well. Pass --target <rest-url> for the REST API surface.");
1034
+ console.error(" Currently only explicit --ops-target + --target combination is supported.");
1035
+ process.exit(1);
1036
+ }
1037
+ const baseUrl = target.replace(/\/$/, "");
1038
+ // --ops-target overrides derivation; otherwise derive from --target
1039
+ const opsUrl = opsTarget ? opsTarget.replace(/\/$/, "") : resolveOpsUrlFromTarget(baseUrl);
1040
+ // Check for cluster-admin provisioning (new atomic flow)
1041
+ const clusterAdminUser = opts.clusterAdminUser || process.env.FLAIR_CLUSTER_ADMIN_USER;
1042
+ const clusterAdminPass = opts.clusterAdminPass || process.env.FLAIR_CLUSTER_ADMIN_PASS;
1043
+ let flairAdminPass = opts.flairAdminPass || process.env.FLAIR_ADMIN_PASS;
1044
+ let didProvision = false;
1045
+ if (clusterAdminUser && clusterAdminPass) {
1046
+ // ── New provisioning path: deploy Flair to Fabric, wait, provision super_user ──
1047
+ if (!opts.force) {
1048
+ console.error("Error: --force is required with --target/--ops-target (remote init provisions a live Fabric instance)");
1049
+ console.error(" Pass --force to confirm this is intended.");
1050
+ process.exit(1);
1051
+ }
1052
+ // Generate flair admin pass if not provided
1053
+ if (!flairAdminPass) {
1054
+ flairAdminPass = randomBytes(24).toString("base64url");
1055
+ }
1056
+ // Write the flair admin pass to secrets directory
1057
+ const secretsDir = join(homedir(), ".tps", "secrets");
1058
+ mkdirSync(secretsDir, { recursive: true });
1059
+ const secretPath = join(secretsDir, "flair-fabric-hdb");
1060
+ writeFileSync(secretPath, flairAdminPass + "\n", { mode: 0o600 });
1061
+ console.log(`Admin password written to ${secretPath}`);
1062
+ // Atomic provisioning: deploy + wait + provision user
1063
+ await provisionFabric(baseUrl, opsUrl, clusterAdminUser, clusterAdminPass, flairAdminPass);
1064
+ didProvision = true;
1065
+ // Hub instances (--remote) receive federation pair requests and need
1066
+ // the flair_pair_initiator role so bootstrap credentials can pass
1067
+ // platform auth before reaching the FederationPair resource handler.
1068
+ if (opts.remote) {
1069
+ await ensureFlairPairInitiatorRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
1070
+ }
1071
+ }
1072
+ else {
1073
+ // ── Existing behavior: --admin-pass required for already-running Flair ──
1074
+ if (!opts.adminPass) {
1075
+ console.error("Error: --admin-pass is required with --target/--ops-target (remote init without --cluster-admin-user/--cluster-admin-pass)");
1076
+ console.error(" Use --cluster-admin-user and --cluster-admin-pass for automated Fabric provisioning.");
1077
+ process.exit(1);
1078
+ }
1079
+ if (!opts.force) {
1080
+ const displayTarget = target || opsTarget;
1081
+ console.error(`Error: --force is required with --target/--ops-target. Remote init writes to a live Flair instance at ${displayTarget}.`);
1082
+ console.error(" Pass --force to confirm this is intended.");
1083
+ process.exit(1);
1084
+ }
1085
+ flairAdminPass = opts.adminPass;
1086
+ }
1087
+ const adminUser = DEFAULT_ADMIN_USER;
1088
+ const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
1089
+ const role = opts.remote ? "hub" : undefined;
1090
+ // Generate or reuse keypair (only if --agent-id provided, or --remote needs
1091
+ // a public key for the FederationInstance row)
1092
+ let pubKeyB64url;
1093
+ let privPath;
1094
+ let instanceId;
1095
+ if (agentId || role) {
1096
+ const keysDir = opts.keysDir ?? defaultKeysDir();
1097
+ mkdirSync(keysDir, { recursive: true });
1098
+ if (agentId) {
1099
+ privPath = privKeyPath(agentId, keysDir);
1100
+ const pubPath = pubKeyPath(agentId, keysDir);
1101
+ if (existsSync(privPath)) {
1102
+ console.log(`Reusing existing key: ${privPath}`);
1103
+ const seed = new Uint8Array(readFileSync(privPath));
1104
+ const kp = nacl.sign.keyPair.fromSeed(seed);
1105
+ pubKeyB64url = b64url(kp.publicKey);
1106
+ }
1107
+ else {
1108
+ console.log("Generating Ed25519 keypair...");
1109
+ const kp = nacl.sign.keyPair();
1110
+ const seed = kp.secretKey.slice(0, 32);
1111
+ writeFileSync(privPath, Buffer.from(seed));
1112
+ chmodSync(privPath, 0o600);
1113
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
1114
+ pubKeyB64url = b64url(kp.publicKey);
1115
+ console.log(`Keypair written: ${privPath} ✓`);
1116
+ }
1117
+ // Seed agent via remote ops API
1118
+ console.log(`Seeding agent '${agentId}' on ${baseUrl}...`);
1119
+ await seedAgentViaOpsApi(opsUrl, agentId, pubKeyB64url, adminUser, flairAdminPass);
1120
+ console.log(`Agent '${agentId}' registered on remote instance ✓`);
1121
+ }
1122
+ else {
1123
+ // No agentId -- generate throwaway keypair for FederationInstance row
1124
+ console.log("Generating federation instance keypair...");
1125
+ const kp = nacl.sign.keyPair();
1126
+ pubKeyB64url = b64url(kp.publicKey);
1127
+ }
1128
+ }
1129
+ else {
1130
+ console.log("No --agent-id provided -- skipping agent registration");
1131
+ }
1132
+ // Write FederationInstance row if --remote (hub role)
1133
+ if (role) {
1134
+ if (!pubKeyB64url) {
1135
+ const kp = nacl.sign.keyPair();
1136
+ pubKeyB64url = b64url(kp.publicKey);
1137
+ }
1138
+ instanceId = randomUUID();
1139
+ console.log(`Writing federation Instance (role=${role}) via ops API...`);
1140
+ await seedFederationInstanceViaOpsApi(opsUrl, instanceId, pubKeyB64url, role, adminUser, flairAdminPass);
1141
+ console.log(`Federation Instance created: ${instanceId} (${role}) ✓`);
1142
+ }
1143
+ // Verify connectivity
1144
+ if (didProvision) {
1145
+ // Use /FederationInstance with Basic auth (not /Health which false-401s on Fabric)
1146
+ console.log("Verifying remote connectivity...");
1147
+ const verifyRes = await fetch(`${baseUrl}/FederationInstance`, {
1148
+ headers: { Authorization: auth },
1149
+ signal: AbortSignal.timeout(5000),
1150
+ });
1151
+ if (!verifyRes.ok) {
1152
+ const body = await verifyRes.text().catch(() => "");
1153
+ console.error(`Remote verification failed (${verifyRes.status}): ${body}`);
1154
+ process.exit(1);
1155
+ }
1156
+ console.log("✓ Hub ready at " + baseUrl);
1157
+ }
1158
+ else {
1159
+ // Existing behavior: /Health check (already-running Flair)
1160
+ console.log("Verifying remote connectivity...");
1161
+ const verifyRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1162
+ if (!verifyRes.ok) {
1163
+ console.error(`Remote health check failed: ${verifyRes.status}`);
1164
+ process.exit(1);
1165
+ }
1166
+ console.log("Remote Flair instance healthy ✓");
1167
+ }
1168
+ // Print summary
1169
+ if (didProvision) {
1170
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8"));
1171
+ const flavor = pkg.version || "(unknown)";
1172
+ const displayTarget = target || opsTarget;
1173
+ console.log(`\n✓ Flair hub deployed to ${displayTarget}`);
1174
+ console.log(` Component: flair@${flavor}`);
1175
+ console.log(` Admin user: ${adminUser} (pass written to ${join(homedir(), ".tps", "secrets", "flair-fabric-hdb")})`);
1176
+ if (instanceId)
1177
+ console.log(` Instance: ${instanceId} (role=${role})`);
1178
+ console.log(` Federation: ready — run \`flair federation token\` to mint a pairing token`);
1179
+ }
1180
+ else {
1181
+ console.log(`\n✅ Remote Flair initialized`);
1182
+ if (agentId)
1183
+ console.log(` Agent ID: ${agentId}`);
1184
+ console.log(` Target: ${baseUrl}`);
1185
+ if (agentId)
1186
+ console.log(` Private key: ${privPath}`);
1187
+ if (role)
1188
+ console.log(` Role: ${role}`);
1189
+ console.log(`\n Export: FLAIR_URL=${baseUrl}`);
1190
+ }
1191
+ return;
1192
+ }
1193
+ // ── Local init (original behavior) ──
556
1194
  const httpPort = resolveHttpPort(opts);
557
1195
  const opsPort = resolveOpsPort(opts);
558
1196
  const keysDir = opts.keysDir ?? defaultKeysDir();
559
1197
  const dataDir = opts.dataDir ?? defaultDataDir();
560
- // Admin password: generate if not provided, NEVER written to disk
561
- const adminPass = opts.adminPass ?? Buffer.from(nacl.randomBytes(18)).toString("base64url");
1198
+ // Admin password: determine from opts, env, or generate
1199
+ // Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
1200
+ let adminPass;
1201
+ let passwordSource = "generated";
1202
+ // Warn if --admin-pass is passed inline (not from env)
1203
+ if (shouldShowInlineSecretWarning(opts.adminPass, false, new Set(["--admin-pass"]), "--admin-pass")) {
1204
+ console.error("warning: --admin-pass passed inline. Consider --admin-pass-file <path> or FLAIR_ADMIN_PASS env " +
1205
+ "to keep secrets out of shell history.");
1206
+ }
1207
+ // Read from file if provided
1208
+ if (opts.adminPassFile) {
1209
+ if (!existsSync(opts.adminPassFile)) {
1210
+ console.error(`Error: --admin-pass-file path does not exist: ${opts.adminPassFile}`);
1211
+ process.exit(1);
1212
+ }
1213
+ const fileContent = readFileSync(opts.adminPassFile, "utf-8");
1214
+ adminPass = fileContent.trim();
1215
+ if (!adminPass) {
1216
+ console.error(`Error: admin password file is empty or contains only whitespace: ${opts.adminPassFile}`);
1217
+ process.exit(1);
1218
+ }
1219
+ passwordSource = "file";
1220
+ }
1221
+ else if (process.env.FLAIR_ADMIN_PASS) {
1222
+ adminPass = process.env.FLAIR_ADMIN_PASS;
1223
+ passwordSource = "env";
1224
+ }
1225
+ else if (process.env.HDB_ADMIN_PASSWORD) {
1226
+ adminPass = process.env.HDB_ADMIN_PASSWORD;
1227
+ passwordSource = "env";
1228
+ }
1229
+ else if (opts.adminPass) {
1230
+ // Inline admin pass (deprecated)
1231
+ adminPass = opts.adminPass;
1232
+ // Don't generate - don't write to file
1233
+ passwordSource = "env"; // Treat same as env for display purposes
1234
+ }
1235
+ else {
1236
+ // Generate new password and write to file atomically
1237
+ adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
1238
+ passwordSource = "generated";
1239
+ // Atomic write: create temp file in same dir, then rename
1240
+ const flairDir = join(homedir(), ".flair");
1241
+ mkdirSync(flairDir, { recursive: true });
1242
+ const adminPassPath = join(flairDir, "admin-pass");
1243
+ const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
1244
+ const finalTempPath = join(tempPath, "admin-pass");
1245
+ try {
1246
+ writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
1247
+ renameSync(finalTempPath, adminPassPath);
1248
+ rmSync(tempPath, { recursive: true, force: true });
1249
+ }
1250
+ catch (err) {
1251
+ // Clean up temp dir on failure
1252
+ try {
1253
+ rmSync(tempPath, { recursive: true, force: true });
1254
+ }
1255
+ catch { }
1256
+ throw err;
1257
+ }
1258
+ }
562
1259
  const adminUser = DEFAULT_ADMIN_USER;
1260
+ // If we generated the password, report where it was saved
1261
+ if (passwordSource === "generated") {
1262
+ const adminPassPath = join(homedir(), ".flair", "admin-pass");
1263
+ console.log(`Admin password saved to: ${adminPassPath}`);
1264
+ }
563
1265
  // Check Node.js version
564
1266
  const major = parseInt(process.version.slice(1), 10);
565
1267
  if (major < 18)
@@ -719,150 +1421,515 @@ program
719
1421
  }
720
1422
  // Persist port to config so other commands can find this instance
721
1423
  writeConfig(httpPort);
722
- // Generate or reuse keypair
723
- mkdirSync(keysDir, { recursive: true });
724
- const privPath = privKeyPath(agentId, keysDir);
725
- const pubPath = pubKeyPath(agentId, keysDir);
726
- let pubKeyB64url;
727
- if (existsSync(privPath)) {
728
- console.log(`Reusing existing key: ${privPath}`);
729
- const seed = new Uint8Array(readFileSync(privPath));
730
- const kp = nacl.sign.keyPair.fromSeed(seed);
731
- pubKeyB64url = b64url(kp.publicKey);
732
- }
733
- else {
734
- console.log("Generating Ed25519 keypair...");
735
- const kp = nacl.sign.keyPair();
736
- // Store only the 32-byte seed (first 32 bytes of secretKey)
737
- const seed = kp.secretKey.slice(0, 32);
738
- writeFileSync(privPath, Buffer.from(seed));
739
- chmodSync(privPath, 0o600);
740
- writeFileSync(pubPath, Buffer.from(kp.publicKey));
741
- pubKeyB64url = b64url(kp.publicKey);
742
- console.log(`Keypair written: ${privPath} ✓`);
743
- }
744
- // Seed agent via operations API
745
- console.log(`Seeding agent '${agentId}' via operations API...`);
746
- await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass);
747
- console.log(`Agent '${agentId}' registered ✓`);
748
- // Verify Ed25519 auth
749
- console.log("Verifying Ed25519 auth...");
750
- const httpUrl = `http://127.0.0.1:${httpPort}`;
751
- const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
752
- if (!verifyRes.ok)
753
- throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
754
- console.log("Ed25519 auth verified ✓");
755
- // Output admin password printed once, never written to disk
756
- console.log("\n✅ Flair initialized successfully");
757
- console.log(` Agent ID: ${agentId}`);
758
- console.log(` Flair URL: ${httpUrl}`);
759
- console.log(` Private key: ${privPath}`);
760
- if (!opts.adminPass && !alreadyRunning) {
761
- console.log(`\n ┌─────────────────────────────────────────────────┐`);
762
- console.log(` │ Harper admin credentials (save these now): │`);
763
- console.log(` │ │`);
764
- console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
765
- console.log(` │ Password: ${adminPass.padEnd(37)}│`);
766
- console.log(` │ │`);
767
- console.log(` │ ⚠️ The password won't be shown again. │`);
768
- console.log(` └─────────────────────────────────────────────────┘`);
769
- }
770
- console.log(`\n Export: FLAIR_URL=${httpUrl}`);
771
- // ── First-run soul setup ──────────────────────────────────────────────
772
- // Interactive wizard to set initial personality (see runSoulWizard).
773
- // Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
774
- //
775
- // Non-TTY / --skip-soul used to seed placeholder text like
776
- // "AI assistant [default]" — it leaked into bootstrap output and
777
- // confused users. Now those paths leave the soul empty and nudge the
778
- // user toward `flair soul set` / `flair doctor` instead.
779
- if (!opts.skipSoul && process.stdin.isTTY) {
780
- const soulEntries = await runSoulWizard(agentId);
781
- if (soulEntries.length > 0) {
782
- console.log("");
783
- for (const [key, value] of soulEntries) {
784
- try {
785
- await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
786
- console.log(` ✓ soul:${key} set`);
787
- }
788
- catch (err) {
789
- console.warn(` ⚠ soul:${key} failed: ${err.message}`);
1424
+ if (agentId) {
1425
+ // Generate or reuse keypair
1426
+ mkdirSync(keysDir, { recursive: true });
1427
+ const privPath = privKeyPath(agentId, keysDir);
1428
+ const pubPath = pubKeyPath(agentId, keysDir);
1429
+ let pubKeyB64url;
1430
+ if (existsSync(privPath)) {
1431
+ console.log(`Reusing existing key: ${privPath}`);
1432
+ const seed = new Uint8Array(readFileSync(privPath));
1433
+ const kp = nacl.sign.keyPair.fromSeed(seed);
1434
+ pubKeyB64url = b64url(kp.publicKey);
1435
+ }
1436
+ else {
1437
+ console.log("Generating Ed25519 keypair...");
1438
+ const kp = nacl.sign.keyPair();
1439
+ // Store only the 32-byte seed (first 32 bytes of secretKey)
1440
+ const seed = kp.secretKey.slice(0, 32);
1441
+ writeFileSync(privPath, Buffer.from(seed));
1442
+ chmodSync(privPath, 0o600);
1443
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
1444
+ pubKeyB64url = b64url(kp.publicKey);
1445
+ console.log(`Keypair written: ${privPath} ✓`);
1446
+ }
1447
+ // Seed agent via operations API
1448
+ console.log(`Seeding agent '${agentId}' via operations API...`);
1449
+ await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass);
1450
+ console.log(`Agent '${agentId}' registered ✓`);
1451
+ // Verify Ed25519 auth
1452
+ console.log("Verifying Ed25519 auth...");
1453
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
1454
+ const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
1455
+ if (!verifyRes.ok)
1456
+ throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
1457
+ console.log("Ed25519 auth verified ✓");
1458
+ // Output admin password printed once, never written to disk
1459
+ console.log("\n✅ Flair initialized successfully");
1460
+ console.log(` Agent ID: ${agentId}`);
1461
+ console.log(` Flair URL: ${httpUrl}`);
1462
+ console.log(` Private key: ${privPath}`);
1463
+ // Display admin credentials when password was generated or from a file
1464
+ // Do NOT display when from env (to avoid showing the env var value)
1465
+ if (passwordSource !== "env" && !alreadyRunning) {
1466
+ const passDisplay = passwordSource === "file"
1467
+ ? opts.adminPassFile ?? "(file path)"
1468
+ : "~/.flair/admin-pass";
1469
+ console.log(`\n ┌─────────────────────────────────────────────────┐`);
1470
+ console.log(` │ Harper admin credentials (save these now): │`);
1471
+ console.log(` │ │`);
1472
+ console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
1473
+ console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
1474
+ console.log(` │ │`);
1475
+ console.log(` │ ⚠️ The password won't be shown again. │`);
1476
+ console.log(` └─────────────────────────────────────────────────┘`);
1477
+ }
1478
+ console.log(`\n Export: FLAIR_URL=${httpUrl}`);
1479
+ // ── First-run soul setup ──────────────────────────────────────────────
1480
+ // Interactive wizard to set initial personality (see runSoulWizard).
1481
+ // Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
1482
+ //
1483
+ // Non-TTY / --skip-soul used to seed placeholder text like
1484
+ // "AI assistant [default]" — it leaked into bootstrap output and
1485
+ // confused users. Now those paths leave the soul empty and nudge the
1486
+ // user toward `flair soul set` / `flair doctor` instead.
1487
+ if (!opts.skipSoul && process.stdin.isTTY) {
1488
+ const soulEntries = await runSoulWizard(agentId);
1489
+ if (soulEntries.length > 0) {
1490
+ console.log("");
1491
+ for (const [key, value] of soulEntries) {
1492
+ try {
1493
+ await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
1494
+ console.log(` ✓ soul:${key} set`);
1495
+ }
1496
+ catch (err) {
1497
+ const message = err instanceof Error ? err.message : String(err);
1498
+ console.warn(` ⚠ soul:${key} failed: ${message}`);
1499
+ }
790
1500
  }
1501
+ console.log(`\n ${soulEntries.length} soul entries saved.`);
1502
+ console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
1503
+ }
1504
+ else {
1505
+ console.log(`\n No soul entries saved. Add later with:`);
1506
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
1507
+ console.log(` Or run \`flair doctor\` anytime for a nudge.`);
791
1508
  }
792
- console.log(`\n ${soulEntries.length} soul entries saved.`);
793
- console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
794
1509
  }
795
1510
  else {
796
- console.log(`\n No soul entries saved. Add later with:`);
1511
+ const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
1512
+ console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
797
1513
  console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
798
- console.log(` Or run \`flair doctor\` anytime for a nudge.`);
799
1514
  }
800
- }
801
- else {
802
- const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
803
- console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
804
- console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
805
- }
806
- console.log(`\n Claude Code: Add to your CLAUDE.md:`);
807
- console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
808
- // Auto-wire MCP config into ~/.claude.json if Claude Code is installed
809
- const claudeJsonPath = join(homedir(), ".claude.json");
810
- const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
811
- const flairMcpConfig = {
812
- type: "stdio",
813
- command: "flair-mcp",
814
- args: [],
815
- env: mcpEnv,
816
- };
817
- try {
818
- if (existsSync(claudeJsonPath)) {
819
- const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
820
- const existing = claudeJson.mcpServers?.flair;
821
- if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
822
- console.log(`\n MCP config already set in ~/.claude.json ✓`);
1515
+ console.log(`\n Claude Code: Add to your CLAUDE.md:`);
1516
+ console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
1517
+ // Auto-wire MCP config into ~/.claude.json if Claude Code is installed
1518
+ const claudeJsonPath = join(homedir(), ".claude.json");
1519
+ const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
1520
+ const flairMcpConfig = {
1521
+ type: "stdio",
1522
+ command: "flair-mcp",
1523
+ args: [],
1524
+ env: mcpEnv,
1525
+ };
1526
+ try {
1527
+ if (existsSync(claudeJsonPath)) {
1528
+ const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1529
+ const existing = claudeJson.mcpServers?.flair;
1530
+ if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
1531
+ console.log(`\n MCP config already set in ~/.claude.json ✓`);
1532
+ }
1533
+ else {
1534
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
1535
+ claudeJson.mcpServers.flair = flairMcpConfig;
1536
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
1537
+ console.log(`\n MCP config written to ~/.claude.json ✓`);
1538
+ console.log(` Restart Claude Code to pick up the new config.`);
1539
+ }
823
1540
  }
824
1541
  else {
825
- claudeJson.mcpServers = claudeJson.mcpServers || {};
826
- claudeJson.mcpServers.flair = flairMcpConfig;
827
- writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
828
- console.log(`\n MCP config written to ~/.claude.json ✓`);
829
- console.log(` Restart Claude Code to pick up the new config.`);
1542
+ console.log(`\n MCP config (add to ~/.claude.json):`);
1543
+ console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
830
1544
  }
831
1545
  }
832
- else {
833
- console.log(`\n MCP config (add to ~/.claude.json):`);
1546
+ catch {
1547
+ console.log(`\n MCP config (add manually to ~/.claude.json):`);
834
1548
  console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
835
1549
  }
836
1550
  }
837
- catch {
838
- console.log(`\n MCP config (add manually to ~/.claude.json):`);
839
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1551
+ else {
1552
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
1553
+ console.log("\n✅ Flair initialized (no agent registered)");
1554
+ console.log(` Flair URL: ${httpUrl}`);
1555
+ // Display admin credentials when password was generated or from a file
1556
+ // Do NOT display when from env (to avoid showing the env var value)
1557
+ if (passwordSource !== "env" && !alreadyRunning) {
1558
+ const passDisplay = passwordSource === "file"
1559
+ ? opts.adminPassFile ?? "(file path)"
1560
+ : "~/.flair/admin-pass";
1561
+ console.log(`\n ┌─────────────────────────────────────────────────┐`);
1562
+ console.log(` │ Harper admin credentials (save these now): │`);
1563
+ console.log(` │ │`);
1564
+ console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
1565
+ console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
1566
+ console.log(` │ │`);
1567
+ console.log(` │ ⚠️ The password won't be shown again. │`);
1568
+ console.log(` └─────────────────────────────────────────────────┘`);
1569
+ }
1570
+ console.log(`\n Export: FLAIR_URL=${httpUrl}`);
840
1571
  }
841
1572
  });
842
- // ─── flair agent ─────────────────────────────────────────────────────────────
843
- const agent = program.command("agent").description("Manage Flair agents");
844
- agent
845
- .command("add <id>")
846
- .description("Register a new agent in a running Flair instance")
847
- .option("--name <name>", "Display name (defaults to id)")
1573
+ // ─── flair install ───────────────────────────────────────────────────────────
1574
+ program
1575
+ .command("install")
1576
+ .description("One-command Flair setup — init, agent, and MCP client wiring")
1577
+ .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
1578
+ .option("--agent <id>", "Agent ID (defaults to hostname short-form)")
1579
+ .option("--no-mcp", "Skip MCP wiring (init + agent only)")
848
1580
  .option("--port <port>", "Harper HTTP port")
849
- .option("--admin-pass <pass>", "Admin password for registration")
850
- .option("--keys-dir <dir>", "Directory for Ed25519 keys")
851
1581
  .option("--ops-port <port>", "Harper operations API port")
852
- .action(async (id, opts) => {
1582
+ .option("--data-dir <dir>", "Harper data directory")
1583
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
1584
+ .option("--skip-smoke", "Skip MCP smoke test")
1585
+ .action(async (opts) => {
853
1586
  const httpPort = resolveHttpPort(opts);
854
1587
  const opsPort = resolveOpsPort(opts);
855
1588
  const keysDir = opts.keysDir ?? defaultKeysDir();
856
- const adminPass = opts.adminPass;
857
- const adminUser = DEFAULT_ADMIN_USER;
858
- const name = opts.name ?? id;
859
- if (!adminPass) {
860
- console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table)");
861
- process.exit(1);
1589
+ const dataDir = opts.dataDir ?? defaultDataDir();
1590
+ // Resolve client selection
1591
+ const clientOpt = opts.client;
1592
+ const noMcp = opts.noMcp === true;
1593
+ const selectedClients = [];
1594
+ if (clientOpt === "none" || noMcp) {
1595
+ // Skip MCP entirely — just init + agent
1596
+ }
1597
+ else if (clientOpt === "all") {
1598
+ // Wire all detected clients
1599
+ }
1600
+ else if (clientOpt) {
1601
+ // Wire a specific client
1602
+ const valid = ["claude-code", "codex", "gemini", "cursor"];
1603
+ if (!valid.includes(clientOpt)) {
1604
+ console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
1605
+ process.exit(1);
1606
+ }
1607
+ selectedClients.push(clientOpt);
862
1608
  }
863
- mkdirSync(keysDir, { recursive: true });
864
- const privPath = privKeyPath(id, keysDir);
865
- const pubPath = pubKeyPath(id, keysDir);
1609
+ // If no --client flag: interactive detection later
1610
+ // ── Step 1: Ensure Flair is initialized ──
1611
+ let alreadyInitialized = false;
1612
+ let alreadyRunning = false;
1613
+ let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
1614
+ const adminUser = DEFAULT_ADMIN_USER;
1615
+ // Check if Flair is already initialized (config with port exists)
1616
+ try {
1617
+ const cp = configPath();
1618
+ if (existsSync(cp)) {
1619
+ const yaml = readFileSync(cp, "utf-8");
1620
+ if (yaml.match(/port:\s*\d+/)) {
1621
+ alreadyInitialized = true;
1622
+ console.log("Flair already initialized — skipping init.");
1623
+ }
1624
+ }
1625
+ }
1626
+ catch { /* not initialized */ }
1627
+ if (!alreadyInitialized) {
1628
+ const major = parseInt(process.version.slice(1), 10);
1629
+ if (major < 18)
1630
+ throw new Error(`Node.js >= 18 required (found ${process.version})`);
1631
+ // Only generate a password if none provided via env (fresh install)
1632
+ // If we generate, write it atomically to ~/.flair/admin-pass
1633
+ let adminPassGenerated = false;
1634
+ if (!adminPass) {
1635
+ adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
1636
+ adminPassGenerated = true;
1637
+ // Atomic write: create temp file in same dir, then rename
1638
+ const flairDir = join(homedir(), ".flair");
1639
+ mkdirSync(flairDir, { recursive: true });
1640
+ const adminPassPath = join(flairDir, "admin-pass");
1641
+ const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
1642
+ const finalTempPath = join(tempPath, "admin-pass");
1643
+ try {
1644
+ writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
1645
+ renameSync(finalTempPath, adminPassPath);
1646
+ rmSync(tempPath, { recursive: true, force: true });
1647
+ }
1648
+ catch (err) {
1649
+ // Clean up temp dir on failure
1650
+ try {
1651
+ rmSync(tempPath, { recursive: true, force: true });
1652
+ }
1653
+ catch { }
1654
+ throw err;
1655
+ }
1656
+ console.log(`Admin password saved to: ${adminPassPath}`);
1657
+ }
1658
+ // Check if Harper is already running on this port
1659
+ try {
1660
+ const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
1661
+ if (res.status > 0) {
1662
+ alreadyRunning = true;
1663
+ console.log(`Harper already running on port ${httpPort} — skipping start`);
1664
+ }
1665
+ }
1666
+ catch { /* not running */ }
1667
+ if (!alreadyRunning) {
1668
+ const bin = harperBin();
1669
+ if (!bin)
1670
+ throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
1671
+ mkdirSync(dataDir, { recursive: true });
1672
+ const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
1673
+ const harperSetConfig = JSON.stringify({
1674
+ rootPath: dataDir,
1675
+ http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
1676
+ operationsApi: { network: { port: opsPort, cors: true }, domainSocket: join(dataDir, "operations-server") },
1677
+ mqtt: { network: { port: null }, webSocket: false },
1678
+ localStudio: { enabled: false },
1679
+ authentication: { authorizeLocal: true, enableSessions: true },
1680
+ });
1681
+ const env = {
1682
+ ...process.env,
1683
+ ROOTPATH: dataDir,
1684
+ HARPER_SET_CONFIG: harperSetConfig,
1685
+ DEFAULTS_MODE: "dev",
1686
+ HDB_ADMIN_USERNAME: adminUser,
1687
+ HDB_ADMIN_PASSWORD: adminPass,
1688
+ THREADS_COUNT: "1",
1689
+ NODE_HOSTNAME: "localhost",
1690
+ HTTP_PORT: String(httpPort),
1691
+ OPERATIONSAPI_NETWORK_PORT: String(opsPort),
1692
+ LOCAL_STUDIO: "false",
1693
+ };
1694
+ if (alreadyInstalled) {
1695
+ console.log("Existing Harper installation found — skipping install.");
1696
+ }
1697
+ else {
1698
+ const installEnv = { ...env, HOME: join(dataDir, "..") };
1699
+ console.log("Installing Harper...");
1700
+ console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
1701
+ await new Promise((resolve, reject) => {
1702
+ let output = "";
1703
+ let dotTimer = null;
1704
+ const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
1705
+ dotTimer = setInterval(() => process.stdout.write("."), 3000);
1706
+ install.stdout?.on("data", (d) => { output += d.toString(); });
1707
+ install.stderr?.on("data", (d) => { output += d.toString(); });
1708
+ install.on("exit", (code) => {
1709
+ if (dotTimer) {
1710
+ clearInterval(dotTimer);
1711
+ process.stdout.write("\n");
1712
+ }
1713
+ code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
1714
+ });
1715
+ install.on("error", (err) => {
1716
+ if (dotTimer) {
1717
+ clearInterval(dotTimer);
1718
+ process.stdout.write("\n");
1719
+ }
1720
+ reject(err);
1721
+ });
1722
+ setTimeout(() => {
1723
+ install.kill();
1724
+ if (dotTimer) {
1725
+ clearInterval(dotTimer);
1726
+ process.stdout.write("\n");
1727
+ }
1728
+ reject(new Error(`Harper install timed out: ${output}`));
1729
+ }, 60_000);
1730
+ });
1731
+ }
1732
+ // Start Harper
1733
+ console.log(`Starting Harper on port ${httpPort}...`);
1734
+ const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
1735
+ proc.unref();
1736
+ }
1737
+ // Wait for health
1738
+ console.log("Waiting for Harper health check...");
1739
+ await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
1740
+ console.log("Harper is healthy ✓");
1741
+ // Write config so other commands can find this instance
1742
+ writeConfig(httpPort);
1743
+ }
1744
+ else {
1745
+ // Flair already initialized — resolve admin pass from env or running instance
1746
+ adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
1747
+ }
1748
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
1749
+ // ── Step 2: Detect MCP clients ──
1750
+ let clients = detectClients();
1751
+ if (selectedClients.length > 0) {
1752
+ // Explicit --client flag — override detection
1753
+ if (clientOpt === "all" || clientOpt === "none" || noMcp) {
1754
+ // all/none handled below
1755
+ }
1756
+ else {
1757
+ // Filter to only the selected client (preserving wire function from detectClients)
1758
+ clients = clients.filter(c => selectedClients.includes(c.id));
1759
+ }
1760
+ }
1761
+ if (!clientOpt && !noMcp) {
1762
+ // No --client flag — print detected clients
1763
+ const detected = clients.filter(c => c.detected);
1764
+ if (detected.length === 0) {
1765
+ console.log("No MCP clients detected. Run with --client <name> to wire a specific client.");
1766
+ }
1767
+ else {
1768
+ console.log(`Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
1769
+ }
1770
+ }
1771
+ // ── Step 3: Resolve agent identity ──
1772
+ const agentId = opts.agent ?? (() => {
1773
+ try {
1774
+ const hn = hostname();
1775
+ return hn.split(".")[0];
1776
+ }
1777
+ catch {
1778
+ return "flair-agent";
1779
+ }
1780
+ })();
1781
+ // Validate agentId to prevent path traversal
1782
+ const VALID_AGENT_ID = /^[a-zA-Z0-9_-]+$/;
1783
+ if (!VALID_AGENT_ID.test(agentId)) {
1784
+ throw new Error(`Invalid agent ID: ${agentId}. Agent ID must contain only letters, numbers, underscores, and hyphens.`);
1785
+ }
1786
+ let agentExists = false;
1787
+ // Check if agent already exists locally
1788
+ const privPath = privKeyPath(agentId, keysDir);
1789
+ if (existsSync(privPath)) {
1790
+ agentExists = true;
1791
+ console.log(`Agent '${agentId}' already exists ✓`);
1792
+ }
1793
+ else {
1794
+ // Create agent
1795
+ mkdirSync(keysDir, { recursive: true });
1796
+ const pubPath = pubKeyPath(agentId, keysDir);
1797
+ console.log("Generating Ed25519 keypair...");
1798
+ const kp = nacl.sign.keyPair();
1799
+ const seed = kp.secretKey.slice(0, 32);
1800
+ writeFileSync(privPath, Buffer.from(seed));
1801
+ chmodSync(privPath, 0o600);
1802
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
1803
+ const pubKeyB64url = b64url(kp.publicKey);
1804
+ console.log(`Keypair written: ${privPath} ✓`);
1805
+ // Seed agent - use REST API for local Harper, Ops API only when --ops-target is specified
1806
+ if (opts.opsTarget) {
1807
+ // Remote Harper via explicit ops target
1808
+ console.log(`Seeding agent '${agentId}' via operations API (--ops-target)...`);
1809
+ await seedAgentViaOpsApi(opts.opsTarget, agentId, pubKeyB64url, adminUser, adminPass);
1810
+ }
1811
+ else {
1812
+ // Local Harper - use REST API
1813
+ console.log(`Seeding agent '${agentId}' via REST API...`);
1814
+ await seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass);
1815
+ }
1816
+ console.log(`Agent '${agentId}' registered ✓`);
1817
+ }
1818
+ // ── Step 4: Wire MCP clients ──
1819
+ const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
1820
+ const wiringResults = [];
1821
+ if (!noMcp && clientOpt !== "none") {
1822
+ const toWire = clientOpt === "all"
1823
+ ? clients.filter(c => c.detected).map(c => c.id)
1824
+ : selectedClients.length > 0
1825
+ ? selectedClients
1826
+ : clients.filter(c => c.detected).map(c => c.id);
1827
+ for (const clientId of toWire) {
1828
+ let result;
1829
+ switch (clientId) {
1830
+ case "claude-code":
1831
+ result = wireClaudeCode(mcpEnv);
1832
+ break;
1833
+ case "codex":
1834
+ result = wireCodex(mcpEnv);
1835
+ break;
1836
+ case "gemini":
1837
+ result = wireGemini(mcpEnv);
1838
+ break;
1839
+ case "cursor":
1840
+ result = wireCursor(mcpEnv);
1841
+ break;
1842
+ default: result = { ok: false, message: `Unknown client: ${clientId}` };
1843
+ }
1844
+ wiringResults.push({ client: clientId, message: result.message });
1845
+ console.log(` ${result.ok ? "✓" : "✗"} ${result.message}`);
1846
+ }
1847
+ }
1848
+ // ── Step 5: Smoke test the MCP server ──
1849
+ if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
1850
+ console.log("Smoke-testing MCP server...");
1851
+ try {
1852
+ // Launch flair-mcp and send initialize request over stdio
1853
+ const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
1854
+ env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
1855
+ stdio: ["pipe", "pipe", "pipe"],
1856
+ timeout: 15_000,
1857
+ });
1858
+ // Send initialize request
1859
+ const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-install", version: "1.0.0" } } });
1860
+ mcpProc.stdin.write(initMsg + "\n");
1861
+ mcpProc.stdin.end();
1862
+ let stdout = "";
1863
+ mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
1864
+ await new Promise((resolve, reject) => {
1865
+ mcpProc.on("exit", (code) => {
1866
+ if (code === 0 && stdout.length > 0) {
1867
+ resolve();
1868
+ }
1869
+ else {
1870
+ reject(new Error(`MCP server exited with code ${code}`));
1871
+ }
1872
+ });
1873
+ mcpProc.on("error", reject);
1874
+ setTimeout(() => { mcpProc.kill(); reject(new Error("MCP smoke test timed out")); }, 15_000);
1875
+ });
1876
+ // Check for a valid JSON-RPC response
1877
+ try {
1878
+ const lines = stdout.split("\n").filter(l => l.trim());
1879
+ for (const line of lines) {
1880
+ const parsed = JSON.parse(line);
1881
+ if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
1882
+ console.log(" ✓ MCP server responded");
1883
+ break;
1884
+ }
1885
+ }
1886
+ }
1887
+ catch {
1888
+ console.log(" ⚠ MCP server responded but response could not be parsed");
1889
+ }
1890
+ }
1891
+ catch (err) {
1892
+ const message = err instanceof Error ? err.message : String(err);
1893
+ console.log(` ⚠ MCP smoke test failed: ${message}`);
1894
+ console.log(" Use --skip-smoke to bypass.");
1895
+ }
1896
+ }
1897
+ // ── Step 6: Summary ──
1898
+ console.log("");
1899
+ console.log("✓ Flair installed.");
1900
+ console.log(` Agent: ${agentId}`);
1901
+ if (existsSync(privKeyPath(agentId, keysDir))) {
1902
+ console.log(` Private key: ${privKeyPath(agentId, keysDir)}`);
1903
+ }
1904
+ console.log(` Local: ${httpUrl}`);
1905
+ console.log(` MCP: ${wiringResults.length > 0 ? wiringResults.map(r => r.client).join(", ") + " ✓ wired" : "none wired"}`);
1906
+ console.log("");
1907
+ console.log(` Try it: in Claude Code, ask the agent "what do you remember about me?"`);
1908
+ });
1909
+ // ─── flair agent ─────────────────────────────────────────────────────────────
1910
+ const agent = program.command("agent").description("Manage Flair agents");
1911
+ agent
1912
+ .command("add <id>")
1913
+ .description("Register a new agent in a running Flair instance")
1914
+ .option("--name <name>", "Display name (defaults to id)")
1915
+ .option("--port <port>", "Harper HTTP port")
1916
+ .option("--admin-pass <pass>", "Admin password for registration")
1917
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
1918
+ .option("--ops-port <port>", "Harper operations API port")
1919
+ .action(async (id, opts) => {
1920
+ const httpPort = resolveHttpPort(opts);
1921
+ const opsPort = resolveOpsPort(opts);
1922
+ const keysDir = opts.keysDir ?? defaultKeysDir();
1923
+ const adminPass = opts.adminPass;
1924
+ const adminUser = DEFAULT_ADMIN_USER;
1925
+ const name = opts.name ?? id;
1926
+ if (!adminPass) {
1927
+ console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table)");
1928
+ process.exit(1);
1929
+ }
1930
+ mkdirSync(keysDir, { recursive: true });
1931
+ const privPath = privKeyPath(id, keysDir);
1932
+ const pubPath = pubKeyPath(id, keysDir);
866
1933
  let pubKeyB64url;
867
1934
  if (existsSync(privPath)) {
868
1935
  console.log(`Reusing existing key: ${privPath}`);
@@ -891,7 +1958,13 @@ agent
891
1958
  .option("--port <port>", "Harper HTTP port")
892
1959
  .action(async (opts) => {
893
1960
  const port = resolveHttpPort(opts);
894
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1961
+ // fromEnv is true ONLY when the resolved value came from env (no inline override).
1962
+ const adminPassFromEnv = !opts.adminPass && (!!process.env.FLAIR_ADMIN_PASS || !!process.env.HDB_ADMIN_PASSWORD);
1963
+ if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
1964
+ console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
1965
+ "to keep secrets out of shell history.");
1966
+ }
1967
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
895
1968
  if (adminPass) {
896
1969
  // Use admin basic auth against ops API to list agents directly
897
1970
  const opsPort = resolveOpsPort(opts);
@@ -899,18 +1972,37 @@ agent
899
1972
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
900
1973
  method: "POST",
901
1974
  headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
902
- body: JSON.stringify({ operation: "sql", sql: "SELECT id, name, createdAt FROM flair.Agent ORDER BY createdAt" }),
1975
+ body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Agent", search_attribute: "id", search_type: "starts_with", search_value: "", get_attributes: ["id", "name", "createdAt"] }),
903
1976
  });
904
1977
  if (!res.ok) {
905
1978
  const text = await res.text().catch(() => "");
906
1979
  console.error(`Error: ${res.status} ${text}`);
907
1980
  process.exit(1);
908
1981
  }
909
- console.log(JSON.stringify(await res.json(), null, 2));
1982
+ const agents = await res.json();
1983
+ agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
1984
+ console.log(JSON.stringify(agents, null, 2));
910
1985
  }
911
1986
  else {
912
- // Try agent-authed API (requires FLAIR_AGENT_ID to be set)
913
- console.log(JSON.stringify(await api("GET", "/Agent"), null, 2));
1987
+ // Localhost operator path: allow IDs-only enumeration without per-agent auth
1988
+ // This treats localhost as a trusted boundary for read-only public metadata
1989
+ const baseUrl = `http://127.0.0.1:${port}`;
1990
+ const res = await fetch(`${baseUrl}/Agent`, {
1991
+ headers: { "Content-Type": "application/json" },
1992
+ });
1993
+ if (!res.ok) {
1994
+ const text = await res.text().catch(() => "");
1995
+ console.error(`Error: ${res.status} ${text}`);
1996
+ process.exit(1);
1997
+ }
1998
+ const data = await res.json();
1999
+ // Filter to IDs-only to respect the localhost trust boundary
2000
+ if (Array.isArray(data)) {
2001
+ console.log(JSON.stringify(data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })), null, 2));
2002
+ }
2003
+ else {
2004
+ console.log(JSON.stringify(data, null, 2));
2005
+ }
914
2006
  }
915
2007
  });
916
2008
  agent
@@ -927,6 +2019,12 @@ agent
927
2019
  .action(async (id, opts) => {
928
2020
  const httpPort = resolveHttpPort(opts);
929
2021
  const opsPort = resolveOpsPort(opts);
2022
+ // fromEnv is true ONLY when the resolved value came from env (no inline override).
2023
+ const adminPassFromEnv = !opts.adminPass && !!process.env.FLAIR_ADMIN_PASS;
2024
+ if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
2025
+ console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
2026
+ "to keep secrets out of shell history.");
2027
+ }
930
2028
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
931
2029
  const adminUser = DEFAULT_ADMIN_USER;
932
2030
  const keysDir = opts.keysDir ?? defaultKeysDir();
@@ -1204,14 +2302,20 @@ principal
1204
2302
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1205
2303
  process.exit(1);
1206
2304
  }
1207
- const kindFilter = opts.kind ? ` WHERE kind = '${opts.kind}'` : "";
1208
2305
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
2306
+ const conditions = opts.kind
2307
+ ? [{ search_attribute: "kind", search_type: "equals", search_value: opts.kind }]
2308
+ : [{ search_attribute: "id", search_type: "starts_with", search_value: "" }];
1209
2309
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1210
2310
  method: "POST",
1211
2311
  headers: { "Content-Type": "application/json", Authorization: auth },
1212
2312
  body: JSON.stringify({
1213
- operation: "sql",
1214
- sql: `SELECT id, name, kind, status, defaultTrustTier, admin, runtime, createdAt FROM flair.Agent${kindFilter} ORDER BY createdAt`,
2313
+ operation: "search_by_conditions",
2314
+ schema: "flair",
2315
+ table: "Agent",
2316
+ operator: "and",
2317
+ conditions,
2318
+ get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "admin", "runtime", "createdAt"],
1215
2319
  }),
1216
2320
  });
1217
2321
  if (!res.ok) {
@@ -1220,6 +2324,7 @@ principal
1220
2324
  process.exit(1);
1221
2325
  }
1222
2326
  const records = await res.json();
2327
+ records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
1223
2328
  if (records.length === 0) {
1224
2329
  console.log("No principals found.");
1225
2330
  return;
@@ -1382,8 +2487,13 @@ idp
1382
2487
  method: "POST",
1383
2488
  headers: { "Content-Type": "application/json", Authorization: auth },
1384
2489
  body: JSON.stringify({
1385
- operation: "sql",
1386
- sql: "SELECT id, name, issuer, requiredDomain, jitProvision, enabled, createdAt FROM flair.IdpConfig ORDER BY createdAt",
2490
+ operation: "search_by_value",
2491
+ schema: "flair",
2492
+ table: "IdpConfig",
2493
+ search_attribute: "id",
2494
+ search_type: "starts_with",
2495
+ search_value: "",
2496
+ get_attributes: ["id", "name", "issuer", "requiredDomain", "jitProvision", "enabled", "createdAt"],
1387
2497
  }),
1388
2498
  });
1389
2499
  if (!res.ok) {
@@ -1392,6 +2502,7 @@ idp
1392
2502
  process.exit(1);
1393
2503
  }
1394
2504
  const records = await res.json();
2505
+ records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
1395
2506
  if (records.length === 0) {
1396
2507
  console.log("No IdPs configured.");
1397
2508
  return;
@@ -1443,18 +2554,14 @@ idp
1443
2554
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1444
2555
  process.exit(1);
1445
2556
  }
1446
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1447
- const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1448
- method: "POST",
1449
- headers: { "Content-Type": "application/json", Authorization: auth },
1450
- body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.IdpConfig WHERE id = '${id}'` }),
1451
- });
1452
- const records = await res.json();
1453
- if (records.length === 0) {
2557
+ let cfg;
2558
+ try {
2559
+ cfg = await api("GET", `/IdpConfig/${id}`);
2560
+ }
2561
+ catch {
1454
2562
  console.error(`IdP '${id}' not found`);
1455
2563
  process.exit(1);
1456
2564
  }
1457
- const cfg = records[0];
1458
2565
  console.log(`Testing IdP: ${cfg.name} (${cfg.issuer})`);
1459
2566
  console.log(` JWKS endpoint: ${cfg.jwksUri}`);
1460
2567
  try {
@@ -1468,7 +2575,8 @@ idp
1468
2575
  console.log(` ✅ JWKS reachable — ${keyCount} key(s) found`);
1469
2576
  }
1470
2577
  catch (err) {
1471
- console.error(` ❌ JWKS fetch error: ${err.message}`);
2578
+ const message = err instanceof Error ? err.message : String(err);
2579
+ console.error(` ❌ JWKS fetch error: ${message}`);
1472
2580
  process.exit(1);
1473
2581
  }
1474
2582
  });
@@ -1581,7 +2689,7 @@ async function loadInstanceSecretKey(instanceId, opts) {
1581
2689
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1582
2690
  method: "POST",
1583
2691
  headers: { "Content-Type": "application/json", Authorization: auth },
1584
- body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.Instance WHERE id = '${instanceId}'` }),
2692
+ body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Instance", search_attribute: "id", search_type: "equals", search_value: instanceId, get_attributes: ["*"] }),
1585
2693
  });
1586
2694
  if (res.ok) {
1587
2695
  const rows = await res.json();
@@ -1598,23 +2706,37 @@ async function loadInstanceSecretKey(instanceId, opts) {
1598
2706
  * Sign a request body and return a new body with the signature field added.
1599
2707
  */
1600
2708
  function signRequestBody(body, secretKey) {
1601
- const sig = signBody(body, secretKey);
1602
- return { ...body, signature: sig };
2709
+ // Fresh signing with anti-replay: embeds _ts and _nonce before signing.
2710
+ // Equivalent to federation-crypto.ts signBodyFresh — duplicated here because
2711
+ // the CLI module has its own local signBody for dependency isolation.
2712
+ const freshBody = {
2713
+ ...body,
2714
+ _ts: Date.now(),
2715
+ _nonce: Buffer.from(nacl.randomBytes(16)).toString("base64url"),
2716
+ };
2717
+ const sig = signBody(freshBody, secretKey);
2718
+ return { ...freshBody, signature: sig };
1603
2719
  }
2720
+ // Alias: signBodyFresh for clarity at call sites
2721
+ const signBodyFresh = signRequestBody;
1604
2722
  // ─── flair federation ────────────────────────────────────────────────────────
1605
2723
  const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
1606
2724
  federation
1607
2725
  .command("status")
1608
2726
  .description("Show federation status and peer connections")
1609
2727
  .option("--port <port>", "Harper HTTP port")
2728
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2729
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
1610
2730
  .action(async (opts) => {
2731
+ const target = resolveTarget(opts);
2732
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
1611
2733
  try {
1612
- const instance = await api("GET", "/FederationInstance");
2734
+ const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
1613
2735
  console.log(`Instance: ${instance.id} (${instance.role})`);
1614
2736
  console.log(`Public key: ${instance.publicKey}`);
1615
2737
  console.log(`Status: ${instance.status}`);
1616
2738
  console.log();
1617
- const { peers } = await api("GET", "/FederationPeers");
2739
+ const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
1618
2740
  if (peers.length === 0) {
1619
2741
  console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
1620
2742
  }
@@ -1632,33 +2754,210 @@ federation
1632
2754
  process.exit(1);
1633
2755
  }
1634
2756
  });
2757
+ // `flair federation reachability` — probe local instance + all paired peers.
2758
+ // Productizes ~/ops/scripts/flair-boot-probe.sh: a single command that tells
2759
+ // you whether memories CAN flow across the federation right now. Read-only;
2760
+ // no mutations, no side effects beyond a single tagged status read per peer.
2761
+ federation
2762
+ .command("reachability")
2763
+ .description("Probe local Flair + each paired peer for reachability (read-only)")
2764
+ .option("--port <port>", "Harper HTTP port")
2765
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2766
+ .option("--quiet", "Suppress output on full success")
2767
+ .option("--json", "Emit machine-readable JSON instead of text")
2768
+ .option("--peer-timeout <seconds>", "HTTP timeout per peer probe (default 5)", "5")
2769
+ .action(async (opts) => {
2770
+ const target = resolveTarget(opts);
2771
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
2772
+ const timeoutMs = (Number(opts.peerTimeout) || 5) * 1000;
2773
+ const results = [];
2774
+ // 1. Local probe.
2775
+ try {
2776
+ const inst = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
2777
+ results.push({ host: "local", port: null, status: "ok", detail: `instance ${inst.id} (${inst.role}, ${inst.status})` });
2778
+ }
2779
+ catch (e) {
2780
+ results.push({ host: "local", port: null, status: "fail", detail: e.message });
2781
+ }
2782
+ // 2. Per-peer probes. For each peer with an `endpoint` (URL), probe it.
2783
+ // Peers without an endpoint are reverse-tunnel-paired (the spoke can't
2784
+ // reach the hub directly without the tunnel) and we skip.
2785
+ let peers = [];
2786
+ try {
2787
+ const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
2788
+ peers = r.peers ?? [];
2789
+ }
2790
+ catch (e) {
2791
+ results.push({ host: "/FederationPeers", port: null, status: "fail", detail: e.message });
2792
+ }
2793
+ for (const p of peers) {
2794
+ const endpoint = p.endpoint;
2795
+ if (!endpoint) {
2796
+ results.push({ host: p.id, port: null, status: "skip", detail: `${p.role ?? "—"} (no endpoint — needs tunnel)` });
2797
+ continue;
2798
+ }
2799
+ // Any HTTP response (including 401) means the peer is reachable + responding.
2800
+ // We're checking the network path, not auth; 401 is expected for unauth probes.
2801
+ // Use new URL() to avoid path-swallowing when endpoint includes a query
2802
+ // (Sherlock review on #314).
2803
+ let probeUrl;
2804
+ try {
2805
+ probeUrl = new URL("/Health", endpoint);
2806
+ }
2807
+ catch {
2808
+ results.push({ host: p.id, port: null, status: "fail", detail: `${p.role ?? "—"} invalid endpoint URL` });
2809
+ continue;
2810
+ }
2811
+ if (probeUrl.protocol !== "http:" && probeUrl.protocol !== "https:") {
2812
+ results.push({ host: p.id, port: null, status: "fail", detail: `${p.role ?? "—"} unsupported protocol ${probeUrl.protocol}` });
2813
+ continue;
2814
+ }
2815
+ try {
2816
+ const ctrl = new AbortController();
2817
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
2818
+ const res = await fetch(probeUrl, { signal: ctrl.signal });
2819
+ clearTimeout(t);
2820
+ results.push({ host: p.id, port: null, status: "ok", detail: `${p.role ?? "—"} HTTP ${res.status}` });
2821
+ }
2822
+ catch (e) {
2823
+ const msg = e.name === "AbortError" ? `timeout after ${opts.peerTimeout}s` : e.message;
2824
+ results.push({ host: p.id, port: null, status: "fail", detail: `${p.role ?? "—"} ${msg}` });
2825
+ }
2826
+ }
2827
+ const failures = results.filter(r => r.status === "fail").length;
2828
+ if (opts.json) {
2829
+ console.log(JSON.stringify({ ts: new Date().toISOString(), failures, results }, null, 2));
2830
+ }
2831
+ else if (!(opts.quiet && failures === 0)) {
2832
+ console.log(`── Flair reachability — ${new Date().toISOString()} ──`);
2833
+ for (const r of results) {
2834
+ const tag = r.status === "ok" ? "OK " : r.status === "skip" ? "SKIP" : "FAIL";
2835
+ console.log(`${tag} ${r.host.padEnd(40)} ${r.detail}`);
2836
+ }
2837
+ if (failures > 0) {
2838
+ console.log(`── ${failures} path(s) FAILED ──`);
2839
+ }
2840
+ else {
2841
+ console.log("── all reachable ──");
2842
+ }
2843
+ }
2844
+ if (failures > 0)
2845
+ process.exit(1);
2846
+ });
2847
+ /** Parse a JSON triple file for --token-from.
2848
+ * Expected shape: { "token": "...", "user": "pair-bootstrap-<id>", "password": "...", "expiresAt": "<ISO>" }
2849
+ * Returns the triple on success. Validation failures exit(1).
2850
+ */
2851
+ function parseTokenFromFile(filePath) {
2852
+ let raw;
2853
+ if (filePath === "-") {
2854
+ raw = readFileSync("/dev/stdin", "utf-8");
2855
+ }
2856
+ else {
2857
+ if (!existsSync(filePath)) {
2858
+ console.error(`Error: --token-from file not found: ${filePath}`);
2859
+ process.exit(1);
2860
+ }
2861
+ raw = readFileSync(filePath, "utf-8");
2862
+ }
2863
+ let parsed;
2864
+ try {
2865
+ parsed = JSON.parse(raw);
2866
+ }
2867
+ catch {
2868
+ console.error(`Error: --token-from file is not valid JSON: ${filePath}`);
2869
+ process.exit(1);
2870
+ }
2871
+ // Validate all four fields present and non-empty
2872
+ const required = ["token", "user", "password", "expiresAt"];
2873
+ for (const field of required) {
2874
+ if (!parsed[field] || typeof parsed[field] !== "string" || parsed[field].trim() === "") {
2875
+ console.error(`Error: --token-from JSON is missing or has empty required field "${field}"`);
2876
+ process.exit(1);
2877
+ }
2878
+ }
2879
+ // Validate expiresAt is a parseable date and is in the future
2880
+ const expiry = new Date(parsed.expiresAt);
2881
+ if (isNaN(expiry.getTime())) {
2882
+ console.error(`Error: --token-from JSON has invalid expiresAt date: "${parsed.expiresAt}"`);
2883
+ process.exit(1);
2884
+ }
2885
+ const now = new Date();
2886
+ if (expiry <= now) {
2887
+ console.error(`Error: --token-from JSON has expired token (expiresAt: ${parsed.expiresAt})`);
2888
+ process.exit(1);
2889
+ }
2890
+ const fiveMin = 5 * 60 * 1000;
2891
+ if (expiry.getTime() - now.getTime() < fiveMin) {
2892
+ console.error(`warning: pairing token expires in less than 5 minutes (expiresAt: ${parsed.expiresAt})`);
2893
+ }
2894
+ return {
2895
+ token: parsed.token.trim(),
2896
+ user: parsed.user.trim(),
2897
+ password: parsed.password.trim(),
2898
+ expiresAt: parsed.expiresAt.trim(),
2899
+ };
2900
+ }
1635
2901
  federation
1636
2902
  .command("pair <hub-url>")
1637
2903
  .description("Pair this spoke with a hub instance")
1638
2904
  .option("--port <port>", "Harper HTTP port")
1639
2905
  .option("--admin-pass <pass>", "Admin password")
1640
2906
  .option("--ops-port <port>", "Harper operations API port")
1641
- .option("--token <token>", "One-time pairing token from hub admin")
2907
+ .option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN) [deprecated: use --token-from]")
2908
+ .option("--token-from <file>", "Read bootstrap triple from JSON file (use '-' for stdin)")
2909
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2910
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
1642
2911
  .action(async (hubUrl, opts) => {
2912
+ const target = resolveTarget(opts);
2913
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
1643
2914
  try {
1644
- const instance = await api("GET", "/FederationInstance");
1645
- console.log(`Local instance: ${instance.id} (${instance.role})`);
1646
- if (!opts.token) {
1647
- console.error("Error: --token is required. Ask the hub admin to run 'flair federation token' and provide the token.");
2915
+ const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
2916
+ console.log(`${target ? "Remote" : "Local"} instance: ${instance.id} (${instance.role})`);
2917
+ // Determine token source: --token-from wins if both specified
2918
+ if (opts.tokenFrom && opts.token) {
2919
+ console.error("warning: --token-from takes precedence over --token. The --token flag is deprecated; use --token-from <file> instead.");
2920
+ }
2921
+ let pairingToken;
2922
+ let authHeader;
2923
+ if (opts.tokenFrom) {
2924
+ // ── Bootstrap triple path (--token-from) ──
2925
+ const triple = parseTokenFromFile(opts.tokenFrom);
2926
+ pairingToken = triple.token;
2927
+ authHeader = `Basic ${Buffer.from(`${triple.user}:${triple.password}`).toString("base64")}`;
2928
+ console.log(`Using bootstrap user: ${triple.user}`);
2929
+ }
2930
+ else if (opts.token) {
2931
+ // ── Bare token path (--token) — deprecated ──
2932
+ pairingToken = opts.token || process.env.FLAIR_PAIRING_TOKEN;
2933
+ console.error("warning: --token is deprecated. Use --token-from <file> to keep credentials out of shell history.");
2934
+ // Warning: inline token may leak to shell history.
2935
+ const tokenFromEnv = !opts.token && !!process.env.FLAIR_PAIRING_TOKEN;
2936
+ if (shouldShowInlineSecretWarning(opts.token, tokenFromEnv, new Set(["--token"]), "--token")) {
2937
+ console.error("warning: --token passed inline. Consider --token-from <file> or FLAIR_PAIRING_TOKEN env " +
2938
+ "to keep secrets out of shell history.");
2939
+ }
2940
+ }
2941
+ else {
2942
+ console.error("Error: --token or --token-from is required. Ask the hub admin to run 'flair federation token' and provide the token.");
1648
2943
  process.exit(1);
1649
2944
  }
1650
- // Load secret key and sign the pairing request
2945
+ // Load secret key and sign the pairing request.
1651
2946
  const secretKey = await loadInstanceSecretKey(instance.id, opts);
1652
2947
  const pairBody = {
1653
2948
  instanceId: instance.id,
1654
2949
  publicKey: instance.publicKey,
1655
2950
  role: "spoke",
1656
- pairingToken: opts.token,
2951
+ pairingToken,
1657
2952
  };
1658
- const signedBody = signRequestBody(pairBody, secretKey);
2953
+ const signedBody = signBodyFresh(pairBody, secretKey);
2954
+ const fetchHeaders = { "Content-Type": "application/json" };
2955
+ if (authHeader) {
2956
+ fetchHeaders.Authorization = authHeader;
2957
+ }
1659
2958
  const res = await fetch(`${hubUrl}/FederationPair`, {
1660
2959
  method: "POST",
1661
- headers: { "Content-Type": "application/json" },
2960
+ headers: fetchHeaders,
1662
2961
  body: JSON.stringify(signedBody),
1663
2962
  });
1664
2963
  if (!res.ok) {
@@ -1668,12 +2967,12 @@ federation
1668
2967
  }
1669
2968
  const result = await res.json();
1670
2969
  console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
1671
- // Record the hub as our peer locally
1672
- const opsPort = resolveOpsPort(opts);
2970
+ // Record the hub as our peer locally or remotely depending on --target
1673
2971
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1674
2972
  if (adminPass) {
1675
2973
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1676
- await fetch(`http://127.0.0.1:${opsPort}/`, {
2974
+ const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
2975
+ await fetch(`${opsEndpoint}/`, {
1677
2976
  method: "POST",
1678
2977
  headers: { "Content-Type": "application/json", Authorization: auth },
1679
2978
  body: JSON.stringify({
@@ -1687,6 +2986,7 @@ federation
1687
2986
  updatedAt: new Date().toISOString(),
1688
2987
  }],
1689
2988
  }),
2989
+ signal: AbortSignal.timeout(10_000),
1690
2990
  });
1691
2991
  }
1692
2992
  }
@@ -1702,16 +3002,21 @@ federation
1702
3002
  .option("--admin-pass <pass>", "Admin password")
1703
3003
  .option("--ops-port <port>", "Harper operations API port")
1704
3004
  .option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
3005
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
3006
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
3007
+ .option("--format <format>", "Output format: json (default) or text (bare token, deprecated)", "json")
1705
3008
  .action(async (opts) => {
3009
+ const target = resolveTarget(opts);
3010
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
1706
3011
  try {
1707
- const { randomBytes } = await import("node:crypto");
1708
3012
  const token = randomBytes(24).toString("base64url");
1709
3013
  const ttlMinutes = parseInt(opts.ttl, 10) || 60;
1710
3014
  const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
1711
- const opsPort = resolveOpsPort(opts);
3015
+ const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
1712
3016
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1713
3017
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1714
- await fetch(`http://127.0.0.1:${opsPort}/`, {
3018
+ // 1. Persist the PairingToken record
3019
+ const opsRes = await fetch(`${opsEndpoint}/`, {
1715
3020
  method: "POST",
1716
3021
  headers: { "Content-Type": "application/json", Authorization: auth },
1717
3022
  body: JSON.stringify({
@@ -1722,78 +3027,514 @@ federation
1722
3027
  expiresAt,
1723
3028
  }],
1724
3029
  }),
3030
+ signal: AbortSignal.timeout(10_000),
1725
3031
  });
1726
- console.log(`Pairing token (expires in ${ttlMinutes}m):`);
1727
- console.log(` ${token}`);
1728
- console.log(`\nGive this to the spoke admin to run:`);
1729
- console.log(` flair federation pair <this-hub-url> --token ${token}`);
3032
+ if (!opsRes.ok) {
3033
+ const detail = await opsRes.text().catch(() => "");
3034
+ throw new Error(`Failed to persist pairing token (${opsRes.status}): ${detail || "no body"}`);
3035
+ }
3036
+ // 2. Create bootstrap user for this token
3037
+ const bootstrapPassword = randomBytes(32).toString("base64url");
3038
+ const bootstrapUsername = `pair-bootstrap-${token.slice(0, 8)}`;
3039
+ let addUserRes;
3040
+ try {
3041
+ addUserRes = await fetch(`${opsEndpoint}/`, {
3042
+ method: "POST",
3043
+ headers: { "Content-Type": "application/json", Authorization: auth },
3044
+ body: JSON.stringify({
3045
+ operation: "add_user",
3046
+ username: bootstrapUsername,
3047
+ password: bootstrapPassword,
3048
+ role: "flair_pair_initiator",
3049
+ active: true,
3050
+ }),
3051
+ signal: AbortSignal.timeout(10_000),
3052
+ });
3053
+ }
3054
+ catch (err) {
3055
+ // Network failure creating bootstrap user — roll back PairingToken
3056
+ await fetch(`${opsEndpoint}/`, {
3057
+ method: "POST",
3058
+ headers: { "Content-Type": "application/json", Authorization: auth },
3059
+ body: JSON.stringify({
3060
+ operation: "delete",
3061
+ database: "flair",
3062
+ table: "PairingToken",
3063
+ hash_value: token,
3064
+ }),
3065
+ signal: AbortSignal.timeout(10_000),
3066
+ }).catch(() => { });
3067
+ throw new Error(`Failed to create bootstrap user (network): ${err.message}`);
3068
+ }
3069
+ if (!addUserRes.ok) {
3070
+ // add_user failed — roll back PairingToken so the two stay in sync
3071
+ await fetch(`${opsEndpoint}/`, {
3072
+ method: "POST",
3073
+ headers: { "Content-Type": "application/json", Authorization: auth },
3074
+ body: JSON.stringify({
3075
+ operation: "delete",
3076
+ database: "flair",
3077
+ table: "PairingToken",
3078
+ hash_value: token,
3079
+ }),
3080
+ signal: AbortSignal.timeout(10_000),
3081
+ }).catch(() => { });
3082
+ const detail = await addUserRes.text().catch(() => "");
3083
+ throw new Error(`Failed to create bootstrap user (${addUserRes.status}): ${detail || "no body"}`);
3084
+ }
3085
+ // 3. Output
3086
+ const format = (opts.format ?? "json").toLowerCase();
3087
+ if (format === "text") {
3088
+ process.stderr.write(`[DEPRECATION] --format text is deprecated. Default output is now JSON.\n`);
3089
+ console.log(token);
3090
+ }
3091
+ else {
3092
+ console.log(JSON.stringify({ token, user: bootstrapUsername, password: bootstrapPassword, expiresAt }, null, 2));
3093
+ }
1730
3094
  }
1731
3095
  catch (err) {
1732
3096
  console.error(`Error: ${err.message}`);
1733
3097
  process.exit(1);
1734
3098
  }
1735
3099
  });
1736
- federation
1737
- .command("sync")
1738
- .description("Push local changes to the hub")
1739
- .option("--port <port>", "Harper HTTP port")
1740
- .option("--admin-pass <pass>", "Admin password")
1741
- .option("--ops-port <port>", "Harper operations API port")
1742
- .action(async (opts) => {
3100
+ export async function runFederationSyncOnce(opts) {
3101
+ const target = resolveTarget(opts);
3102
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
3103
+ const apiOpts = baseUrl ? { baseUrl } : undefined;
3104
+ let totalMerged = 0;
3105
+ let totalSkipped = 0;
1743
3106
  try {
1744
- const { peers } = await api("GET", "/FederationPeers");
3107
+ const { peers } = await api("GET", "/FederationPeers", undefined, apiOpts);
1745
3108
  const hub = peers.find((p) => p.role === "hub" && p.status !== "revoked");
1746
3109
  if (!hub) {
1747
- console.error("No hub peer configured. Use 'flair federation pair' first.");
1748
- process.exit(1);
3110
+ return { pushed: 0, skipped: 0, error: new Error("No hub peer configured. Use 'flair federation pair' first.") };
1749
3111
  }
1750
3112
  console.log(`Syncing to hub: ${hub.id}...`);
1751
3113
  const since = hub.lastSyncAt ?? new Date(0).toISOString();
1752
- const opsPort = resolveOpsPort(opts);
3114
+ const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
1753
3115
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1754
3116
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1755
3117
  const tables = ["Memory", "Soul", "Agent", "Relationship"];
1756
- const records = [];
1757
- const instance = await api("GET", "/FederationInstance");
3118
+ const instance = await api("GET", "/FederationInstance", undefined, apiOpts);
3119
+ const hubUrl = hub.endpoint ?? hub.id;
3120
+ // ── Batching constants ──────────────────────────────────────────────
3121
+ // 2MB JSON budget (server cap is 10MB; 2MB leaves headroom for headers
3122
+ // and signature metadata) + 200 records max per batch.
3123
+ const BUDGET_BYTES = 2_000_000;
3124
+ const BUDGET_RECORDS = 200;
3125
+ // ── sendBatch helper ────────────────────────────────────────────────
3126
+ // Secret key is lazy-loaded: only needed when there are records to send.
3127
+ // Loading earlier would cause a spurious error when SQL queries fail
3128
+ // (e.g. 401) before we know we have records.
3129
+ let secretKey;
3130
+ async function sendBatch(batch) {
3131
+ if (!secretKey)
3132
+ secretKey = await loadInstanceSecretKey(instance.id, opts);
3133
+ const syncBody = { instanceId: instance.id, records: batch, lamportClock: Date.now() };
3134
+ const signedSyncBody = signBodyFresh(syncBody, secretKey);
3135
+ const syncRes = await fetch(`${hubUrl}/FederationSync`, {
3136
+ method: "POST",
3137
+ headers: { "Content-Type": "application/json" },
3138
+ body: JSON.stringify(signedSyncBody),
3139
+ });
3140
+ if (!syncRes.ok) {
3141
+ const text = await syncRes.text().catch(() => "");
3142
+ throw new Error(`Sync batch failed: ${syncRes.status} ${text}`);
3143
+ }
3144
+ return await syncRes.json();
3145
+ }
3146
+ let totalBatches = 0;
1758
3147
  for (const table of tables) {
3148
+ let res;
1759
3149
  try {
1760
- const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
3150
+ res = await fetch(`${opsEndpoint}/`, {
1761
3151
  method: "POST",
1762
3152
  headers: { "Content-Type": "application/json", Authorization: auth },
1763
- body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.${table} WHERE updatedAt > '${since}'` }),
3153
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [{ search_attribute: "updatedAt", search_type: "greater_than", search_value: since }], get_attributes: ["*"] }),
3154
+ signal: AbortSignal.timeout(15_000),
1764
3155
  });
1765
- if (res.ok) {
1766
- for (const row of await res.json()) {
1767
- records.push({ table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id });
1768
- }
3156
+ }
3157
+ catch (err) {
3158
+ return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
3159
+ }
3160
+ if (!res.ok) {
3161
+ const text = await res.text().catch(() => "");
3162
+ return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3163
+ }
3164
+ // Stream-collect records into batches
3165
+ const rows = await res.json();
3166
+ if (rows.length === 0)
3167
+ continue;
3168
+ let batch = [];
3169
+ let batchBytes = 0;
3170
+ for (const row of rows) {
3171
+ const sr = { table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id };
3172
+ const srBytes = JSON.stringify(sr).length;
3173
+ if (batch.length >= BUDGET_RECORDS || (batch.length > 0 && batchBytes + srBytes > BUDGET_BYTES)) {
3174
+ const result = await sendBatch(batch);
3175
+ totalMerged += result.merged;
3176
+ totalSkipped += result.skipped;
3177
+ totalBatches++;
3178
+ batch = [];
3179
+ batchBytes = 0;
1769
3180
  }
3181
+ batch.push(sr);
3182
+ batchBytes += srBytes;
3183
+ }
3184
+ // Send final partial batch for this table
3185
+ if (batch.length > 0) {
3186
+ const result = await sendBatch(batch);
3187
+ totalMerged += result.merged;
3188
+ totalSkipped += result.skipped;
3189
+ totalBatches++;
1770
3190
  }
1771
- catch { }
1772
3191
  }
1773
- if (records.length === 0) {
3192
+ if (totalBatches === 0) {
1774
3193
  console.log("No changes since last sync.");
1775
- return;
1776
- }
1777
- // Sign the sync request with our instance key
1778
- const secretKey = await loadInstanceSecretKey(instance.id, opts);
1779
- const syncBody = { instanceId: instance.id, records, lamportClock: Date.now() };
1780
- const signedSyncBody = signRequestBody(syncBody, secretKey);
1781
- const syncRes = await fetch(`${hub.endpoint ?? hub.id}/FederationSync`, {
1782
- method: "POST",
1783
- headers: { "Content-Type": "application/json" },
1784
- body: JSON.stringify(signedSyncBody),
1785
- });
1786
- if (!syncRes.ok) {
1787
- console.error(`Sync failed: ${syncRes.status} ${await syncRes.text().catch(() => "")}`);
1788
- process.exit(1);
3194
+ return { pushed: 0, skipped: 0 };
1789
3195
  }
1790
- const result = await syncRes.json();
1791
- console.log(`✅ Synced ${result.merged} records (${result.skipped} skipped) in ${result.durationMs}ms`);
3196
+ console.log(`✅ Synced ${totalMerged} records (${totalSkipped} skipped) across ${totalBatches} batches`);
3197
+ return { pushed: totalMerged, skipped: totalSkipped };
1792
3198
  }
1793
3199
  catch (err) {
1794
- console.error(`Error: ${err.message}`);
3200
+ return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
3201
+ }
3202
+ }
3203
+ federation
3204
+ .command("sync")
3205
+ .description("Push local changes to the hub")
3206
+ .option("--port <port>", "Harper HTTP port")
3207
+ .option("--admin-pass <pass>", "Admin password")
3208
+ .option("--ops-port <port>", "Harper operations API port")
3209
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
3210
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
3211
+ .action(async (opts) => {
3212
+ const r = await runFederationSyncOnce(opts);
3213
+ if (r.error) {
3214
+ console.error(`Error: ${r.error.message}`);
3215
+ process.exit(1);
3216
+ }
3217
+ });
3218
+ export async function runFederationWatch(opts) {
3219
+ const intervalMs = Math.max(5, parseFloat(opts.interval) || 30) * 1000;
3220
+ let stopped = false;
3221
+ const stop = () => { stopped = true; };
3222
+ process.on("SIGINT", stop);
3223
+ process.on("SIGTERM", stop);
3224
+ console.log(`flair federation watch — interval ${intervalMs / 1000}s. Ctrl-C to stop.`);
3225
+ try {
3226
+ while (!stopped) {
3227
+ try {
3228
+ const r = await runFederationSyncOnce(opts);
3229
+ const ts = new Date().toISOString();
3230
+ if (r.error)
3231
+ console.error(`[${ts}] sync error: ${r.error.message}`);
3232
+ else
3233
+ console.log(`[${ts}] sync ok — pushed ${r.pushed}, skipped ${r.skipped}`);
3234
+ }
3235
+ catch (err) {
3236
+ console.error(`[${new Date().toISOString()}] watch loop error: ${err.message}`);
3237
+ }
3238
+ // Sleep but exit early on signal
3239
+ const t = Date.now();
3240
+ while (!stopped && Date.now() - t < intervalMs) {
3241
+ const remaining = intervalMs - (Date.now() - t);
3242
+ await new Promise((r) => setTimeout(r, Math.min(250, remaining)));
3243
+ }
3244
+ }
3245
+ }
3246
+ finally {
3247
+ process.removeListener("SIGINT", stop);
3248
+ process.removeListener("SIGTERM", stop);
3249
+ }
3250
+ console.log("flair federation watch — stopped.");
3251
+ }
3252
+ federation
3253
+ .command("watch")
3254
+ .description("Run federation sync in a loop (foreground daemon)")
3255
+ .option("--interval <seconds>", "Seconds between syncs", "30")
3256
+ .option("--port <port>", "Harper HTTP port")
3257
+ .option("--admin-pass <pass>", "Admin password")
3258
+ .option("--ops-port <port>", "Harper operations API port")
3259
+ .option("--target <url>", "Remote Flair URL")
3260
+ .option("--ops-target <url>", "Explicit ops API URL")
3261
+ .action(async (opts) => {
3262
+ await runFederationWatch(opts);
3263
+ });
3264
+ // `flair federation prune` — remove stale spoke peers (never the hub).
3265
+ // Productizes ~/ops/scripts/cleanup-stale-fed-peers.sh into a real CLI
3266
+ // subcommand with safety: dry-run is the default, --apply required to delete.
3267
+ function parseDuration(spec) {
3268
+ // Accept forms like "30d", "12h", "90m". Returns milliseconds.
3269
+ // Rejects zero and sub-1-minute durations: a 0-ms cutoff would equal Date.now()
3270
+ // and prune every non-hub peer (Sherlock review on #314).
3271
+ const m = spec.match(/^(\d+)\s*([smhd])$/i);
3272
+ if (!m)
3273
+ return null;
3274
+ const n = Number(m[1]);
3275
+ const unit = m[2].toLowerCase();
3276
+ const mul = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 }[unit] ?? null;
3277
+ if (mul == null)
3278
+ return null;
3279
+ const ms = n * mul;
3280
+ const ONE_MINUTE = 60 * 1000;
3281
+ if (ms < ONE_MINUTE)
3282
+ return null;
3283
+ return ms;
3284
+ }
3285
+ federation
3286
+ .command("prune")
3287
+ .description("Remove stale spoke peers (older than --older-than). Hub is never pruned. Default dry-run.")
3288
+ .option("--older-than <duration>", "Duration spec (e.g. 30d, 12h, 90m)", "30d")
3289
+ .option("--apply", "Actually delete (default is dry-run)")
3290
+ .option("--include <pattern>", "Only consider peer IDs starting with this prefix")
3291
+ .option("--port <port>", "Harper HTTP port")
3292
+ .option("--ops-port <port>", "Harper operations API port")
3293
+ .option("--target <url>", "Remote Flair URL")
3294
+ .option("--ops-target <url>", "Explicit ops API URL")
3295
+ .action(async (opts) => {
3296
+ const target = resolveTarget(opts);
3297
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
3298
+ const olderThanMs = parseDuration(opts.olderThan);
3299
+ if (olderThanMs == null) {
3300
+ console.error(`Error: invalid or unsafe --older-than '${opts.olderThan}'. Use forms like 30d, 12h, 90m. Minimum 1 minute.`);
3301
+ process.exit(2);
3302
+ }
3303
+ const cutoff = Date.now() - olderThanMs;
3304
+ let peers = [];
3305
+ try {
3306
+ const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
3307
+ peers = r.peers ?? [];
3308
+ }
3309
+ catch (e) {
3310
+ console.error(`Error fetching peers: ${e.message}`);
3311
+ process.exit(1);
3312
+ }
3313
+ const candidates = peers.filter(p => {
3314
+ // Hub-protection: never prune. Case-insensitive; null/undefined role is
3315
+ // treated as "unknown — refuse to prune to be safe" (Sherlock review on #314).
3316
+ const role = (p.role ?? "").toString().toLowerCase();
3317
+ if (role === "hub" || role === "")
3318
+ return false;
3319
+ // Include filter.
3320
+ if (opts.include && !String(p.id ?? "").startsWith(opts.include))
3321
+ return false;
3322
+ // Stale threshold: a peer with NO lastSyncAt is treated as having been
3323
+ // born and immediately abandoned — qualifies if it's older than the
3324
+ // threshold based on pairedAt instead.
3325
+ const ts = p.lastSyncAt ?? p.pairedAt;
3326
+ if (!ts)
3327
+ return true; // truly orphaned record — prune candidate.
3328
+ return new Date(ts).getTime() < cutoff;
3329
+ });
3330
+ if (candidates.length === 0) {
3331
+ console.log(`flair federation prune: no peers older than ${opts.olderThan} (and not hub) — nothing to do.`);
3332
+ return;
3333
+ }
3334
+ if (!opts.apply) {
3335
+ console.log(`── flair federation prune — dry-run (use --apply to delete) ──`);
3336
+ console.log(`Would delete ${candidates.length} peer(s) older than ${opts.olderThan}:`);
3337
+ for (const p of candidates) {
3338
+ const ts = p.lastSyncAt ?? p.pairedAt ?? "never";
3339
+ const age = ts === "never" ? "(never synced/paired)" : `${Math.floor((Date.now() - new Date(ts).getTime()) / (24 * 60 * 60 * 1000))}d ago`;
3340
+ console.log(` ${p.id} ${(p.role ?? "—").padEnd(8)} lastSyncAt ${ts} (${age})`);
3341
+ }
3342
+ console.log(`Run with --apply to actually delete.`);
3343
+ return;
3344
+ }
3345
+ // Apply path. Delete each peer via the Harper ops API. We use the
3346
+ // domain-socket form when local; otherwise we fall back to the resource
3347
+ // DELETE which requires admin auth.
3348
+ let deleted = 0;
3349
+ let errors = 0;
3350
+ for (const p of candidates) {
3351
+ try {
3352
+ const res = await api("DELETE", `/FederationPeers/${encodeURIComponent(p.id)}`, undefined, baseUrl ? { baseUrl } : undefined);
3353
+ const ok = res?.ok ?? res?.deleted ?? true;
3354
+ if (ok) {
3355
+ deleted++;
3356
+ const ts = p.lastSyncAt ?? p.pairedAt ?? "never";
3357
+ console.log(`Deleted ${p.id} (last seen ${ts}).`);
3358
+ }
3359
+ else {
3360
+ errors++;
3361
+ console.log(`Failed to delete ${p.id}: ${JSON.stringify(res)}`);
3362
+ }
3363
+ }
3364
+ catch (e) {
3365
+ errors++;
3366
+ console.log(`Failed to delete ${p.id}: ${e.message}`);
3367
+ }
3368
+ }
3369
+ console.log(`${deleted} peer(s) deleted; ${errors} error(s).`);
3370
+ if (errors > 0)
3371
+ process.exit(1);
3372
+ });
3373
+ // `flair federation verify` — end-to-end roundtrip: write a tagged memory
3374
+ // locally, wait for federation push, probe peers for the tag. Productizes
3375
+ // ~/ops/scripts/verify-fed-sync.sh. Cleans up the test memory at the end.
3376
+ federation
3377
+ .command("verify")
3378
+ .description("End-to-end check: write a tagged memory locally and verify it shows up on each peer")
3379
+ .option("--peer <id>", "Verify only against this peer ID (default: all hubs + spokes)")
3380
+ .option("--wait <seconds>", "How long to wait for federation push (default 60)", "60")
3381
+ .option("--tag <prefix>", "Memory tag prefix (default: fed-verify)", "fed-verify")
3382
+ .option("--port <port>", "Harper HTTP port")
3383
+ .option("--target <url>", "Remote Flair URL")
3384
+ .action(async (opts) => {
3385
+ const target = resolveTarget(opts);
3386
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
3387
+ const waitMs = (Number(opts.wait) || 60) * 1000;
3388
+ const tag = `${opts.tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3389
+ console.log(`── flair federation verify — ${new Date().toISOString()} ──`);
3390
+ console.log(`Tag: ${tag}, wait window: ${opts.wait}s`);
3391
+ // Resolve agent ID for the local write.
3392
+ const agentId = process.env.FLAIR_AGENT_ID;
3393
+ if (!agentId) {
3394
+ console.error("Error: FLAIR_AGENT_ID not set. Set it or use 'flair agent default <id>'.");
1795
3395
  process.exit(1);
1796
3396
  }
3397
+ // 1. Write tagged ephemeral memory locally (mirror `flair memory add`).
3398
+ // The whole post-write block is wrapped in try/finally so cleanup runs on
3399
+ // every exit path (Sherlock review on #314: previous early-exits leaked
3400
+ // the probe memory).
3401
+ const memId = `${agentId}-${Date.now()}-fed-verify`;
3402
+ try {
3403
+ await api("PUT", `/Memory/${encodeURIComponent(memId)}`, {
3404
+ id: memId,
3405
+ agentId,
3406
+ content: `${tag} — federation verify probe written at ${new Date().toISOString()}`,
3407
+ type: "memory",
3408
+ durability: "ephemeral",
3409
+ tags: ["federation-verify", tag],
3410
+ createdAt: new Date().toISOString(),
3411
+ }, baseUrl ? { baseUrl } : undefined);
3412
+ console.log(`1. Wrote local memory: ${memId}`);
3413
+ }
3414
+ catch (e) {
3415
+ console.error(`1. Local write FAILED: ${e.message}`);
3416
+ // No memory was written — nothing to clean up. Direct exit is safe.
3417
+ process.exit(1);
3418
+ }
3419
+ // From here, memId is committed and MUST be cleaned up regardless of how
3420
+ // we leave this block.
3421
+ let exitCode = 0;
3422
+ try {
3423
+ // 2. List peers to probe.
3424
+ let peers = [];
3425
+ try {
3426
+ const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
3427
+ peers = r.peers ?? [];
3428
+ if (opts.peer)
3429
+ peers = peers.filter(p => p.id === opts.peer);
3430
+ }
3431
+ catch (e) {
3432
+ console.error(`Failed to list peers: ${e.message}`);
3433
+ }
3434
+ if (peers.length === 0) {
3435
+ console.log("(no peers to probe)");
3436
+ // Still falls through to finally for cleanup.
3437
+ }
3438
+ else {
3439
+ console.log(`2. Probing ${peers.length} peer(s) over ${opts.wait}s window…`);
3440
+ // 3. Poll each peer until found OR window elapses.
3441
+ const started = Date.now();
3442
+ const found = new Set();
3443
+ const failed = new Set();
3444
+ while (Date.now() - started < waitMs && (found.size + failed.size) < peers.length) {
3445
+ for (const p of peers) {
3446
+ if (found.has(p.id) || failed.has(p.id))
3447
+ continue;
3448
+ const endpoint = p.endpoint;
3449
+ if (!endpoint) {
3450
+ // Tunnel-paired — no direct endpoint to probe. Mark as skipped.
3451
+ failed.add(p.id);
3452
+ console.log(` ${p.id} SKIP (no endpoint — tunnel-paired)`);
3453
+ continue;
3454
+ }
3455
+ // Reject non-http(s) endpoints to keep the probe surface small.
3456
+ // (Sherlock review on #314 — protocol allowlist.)
3457
+ let probeUrl;
3458
+ try {
3459
+ probeUrl = new URL("/SemanticSearch", endpoint);
3460
+ }
3461
+ catch {
3462
+ failed.add(p.id);
3463
+ console.log(` ${p.id} FAIL (invalid endpoint URL: ${endpoint})`);
3464
+ continue;
3465
+ }
3466
+ if (probeUrl.protocol !== "http:" && probeUrl.protocol !== "https:") {
3467
+ failed.add(p.id);
3468
+ console.log(` ${p.id} FAIL (unsupported endpoint protocol: ${probeUrl.protocol})`);
3469
+ continue;
3470
+ }
3471
+ try {
3472
+ const res = await fetch(probeUrl, {
3473
+ method: "POST",
3474
+ headers: { "Content-Type": "application/json" },
3475
+ body: JSON.stringify({ q: tag, limit: 5 }),
3476
+ signal: AbortSignal.timeout(5000),
3477
+ });
3478
+ if (res.status === 401) {
3479
+ // Auth-gated — can't verify without admin creds for the peer.
3480
+ // Fail the probe with diagnostic.
3481
+ failed.add(p.id);
3482
+ console.log(` ${p.id} FAIL (HTTP 401 — peer auth-gated; needs cross-instance admin auth)`);
3483
+ continue;
3484
+ }
3485
+ if (!res.ok) {
3486
+ failed.add(p.id);
3487
+ console.log(` ${p.id} FAIL (HTTP ${res.status})`);
3488
+ continue;
3489
+ }
3490
+ const data = await res.json().catch(() => ({}));
3491
+ const results = data.results ?? [];
3492
+ if (results.some((r) => (r.content ?? "").includes(tag))) {
3493
+ const elapsed = Math.floor((Date.now() - started) / 1000);
3494
+ console.log(` ${p.id} OK (memory found after ${elapsed}s)`);
3495
+ found.add(p.id);
3496
+ }
3497
+ }
3498
+ catch {
3499
+ // Don't mark failed yet — peer might just be slow. Retry next iteration.
3500
+ }
3501
+ }
3502
+ await new Promise(res => setTimeout(res, 5000));
3503
+ }
3504
+ // Anything still pending is a timeout failure.
3505
+ for (const p of peers) {
3506
+ if (!found.has(p.id) && !failed.has(p.id)) {
3507
+ failed.add(p.id);
3508
+ console.log(` ${p.id} FAIL (timeout — memory did not propagate within ${opts.wait}s)`);
3509
+ }
3510
+ }
3511
+ // 5. Summary + diagnostics on failure.
3512
+ if (failed.size > 0) {
3513
+ console.log(`── FAIL: ${failed.size}/${peers.length} peer(s) did not see the memory ──`);
3514
+ console.log(`Diagnostics to run next:`);
3515
+ console.log(` flair federation status # confirm peers are paired + lastSyncAt is recent`);
3516
+ console.log(` flair federation reachability # confirm peers are HTTP-reachable`);
3517
+ console.log(` launchctl list | grep fed-sync # confirm federation-sync daemon is running (macOS)`);
3518
+ console.log(` curl <peer-endpoint>/Health # raw probe`);
3519
+ exitCode = 1;
3520
+ }
3521
+ else {
3522
+ console.log(`── PASS: memory propagated to all ${peers.length} peer(s) ──`);
3523
+ }
3524
+ }
3525
+ }
3526
+ finally {
3527
+ // 4. Cleanup: delete the local probe memory. Runs on EVERY exit path.
3528
+ try {
3529
+ await api("DELETE", `/Memory/${encodeURIComponent(memId)}`, undefined, baseUrl ? { baseUrl } : undefined);
3530
+ console.log(`4. Cleanup: deleted local memory ${memId}`);
3531
+ }
3532
+ catch {
3533
+ console.log(`4. Cleanup: could NOT delete local memory ${memId} (manual cleanup needed)`);
3534
+ }
3535
+ }
3536
+ if (exitCode !== 0)
3537
+ process.exit(exitCode);
1797
3538
  });
1798
3539
  // ─── flair rem ───────────────────────────────────────────────────────────────
1799
3540
  // Memory hygiene and reflection: light (NREM), rapid (REM), restorative (deep).
@@ -1956,31 +3697,294 @@ rem
1956
3697
  }
1957
3698
  }
1958
3699
  else {
1959
- console.log("\n[2/3] Consolidation skipped — no agent ID");
1960
- }
1961
- // Step 3: Reflection
1962
- if (agentId) {
1963
- console.log("\n[3/3] Reflection (scope=all)...");
1964
- const reflect = await api("POST", "/ReflectMemories", {
1965
- agentId,
1966
- scope: "all",
3700
+ console.log("\n[2/3] Consolidation skipped — no agent ID");
3701
+ }
3702
+ // Step 3: Reflection
3703
+ if (agentId) {
3704
+ console.log("\n[3/3] Reflection (scope=all)...");
3705
+ const reflect = await api("POST", "/ReflectMemories", {
3706
+ agentId,
3707
+ scope: "all",
3708
+ });
3709
+ if (reflect.error) {
3710
+ console.error(`Reflection error: ${reflect.error}`);
3711
+ process.exit(1);
3712
+ }
3713
+ console.log(` Source memories: ${reflect.count ?? 0}`);
3714
+ if (reflect.suggestedTags?.length) {
3715
+ console.log(` Tags: ${reflect.suggestedTags.join(", ")}`);
3716
+ }
3717
+ console.log("\n--- Reflection Prompt ---");
3718
+ console.log(reflect.prompt ?? "(no prompt returned)");
3719
+ console.log("--- End Prompt ---");
3720
+ }
3721
+ else {
3722
+ console.log("\n[3/3] Reflection skipped — no agent ID");
3723
+ }
3724
+ console.log(`\nRestorative cycle complete.${dryRun ? " No changes applied (dry run)." : ""}`);
3725
+ }
3726
+ catch (err) {
3727
+ console.error(`Error: ${err.message}`);
3728
+ process.exit(1);
3729
+ }
3730
+ });
3731
+ // ─── flair rem candidates ─────────────────────────────────────────────────────
3732
+ // Slice 1 of FLAIR-NIGHTLY-REM (ops-2qq). Lists staged distillations from the
3733
+ // MemoryCandidate table. Empty until the nightly cycle (later slice) starts
3734
+ // populating. Per spec § 5: candidates are NEVER auto-promoted; this command
3735
+ // is the operator's review surface.
3736
+ rem
3737
+ .command("candidates")
3738
+ .description("List staged memory candidates from the FLAIR-NIGHTLY-REM cycle (pending review)")
3739
+ .option("--port <port>", "Harper HTTP port")
3740
+ .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
3741
+ .option("--status <s>", "Filter by status: pending | promoted | rejected (default: pending)")
3742
+ .option("--json", "Output as JSON for scripting")
3743
+ .action(async (opts) => {
3744
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
3745
+ const status = opts.status ?? "pending";
3746
+ const validStatuses = new Set(["pending", "promoted", "rejected"]);
3747
+ if (!validStatuses.has(status)) {
3748
+ console.error(`Error: --status must be one of: pending, promoted, rejected (got: ${status})`);
3749
+ process.exit(1);
3750
+ }
3751
+ if (!agentId) {
3752
+ console.error("Error: --agent is required (or set FLAIR_AGENT_ID)");
3753
+ process.exit(1);
3754
+ }
3755
+ try {
3756
+ const result = await api("POST", "/MemoryCandidate/search_by_conditions", {
3757
+ operator: "and",
3758
+ conditions: [
3759
+ { search_attribute: "agentId", search_type: "equals", search_value: agentId },
3760
+ { search_attribute: "status", search_type: "equals", search_value: status },
3761
+ ],
3762
+ get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
3763
+ });
3764
+ // search_by_conditions returns either an array or { error }; tolerate both shapes
3765
+ const candidates = Array.isArray(result) ? result : (result?.results ?? []);
3766
+ if (opts.json) {
3767
+ console.log(JSON.stringify({ agentId, status, count: candidates.length, candidates }, null, 2));
3768
+ return;
3769
+ }
3770
+ console.log(`\n-- rem candidates (agent=${agentId}, status=${status}) --\n`);
3771
+ if (candidates.length === 0) {
3772
+ console.log(`No ${status} candidates.`);
3773
+ if (status === "pending") {
3774
+ console.log("\n(Run `flair rem nightly enable` to start the nightly distillation cycle that populates this table.)");
3775
+ }
3776
+ return;
3777
+ }
3778
+ // Sort newest-first by generatedAt
3779
+ candidates.sort((a, b) => String(b.generatedAt ?? "").localeCompare(String(a.generatedAt ?? "")));
3780
+ for (const c of candidates) {
3781
+ const tag = c.status === "promoted"
3782
+ ? `[promoted → ${c.target ?? "?"} by ${c.reviewerId ?? "?"} @ ${relativeTime(c.decidedAt)}]`
3783
+ : c.status === "rejected"
3784
+ ? `[rejected by ${c.reviewerId ?? "?"} @ ${relativeTime(c.decidedAt)}]`
3785
+ : `[pending — ${c.generatedBy ?? "?"} @ ${relativeTime(c.generatedAt)}]`;
3786
+ console.log(` ${c.id} ${tag}`);
3787
+ console.log(` ${c.claim}`);
3788
+ if (c.supersedes)
3789
+ console.log(` (supersedes ${c.supersedes} — recurring proposal)`);
3790
+ console.log("");
3791
+ }
3792
+ console.log(`${candidates.length} candidate${candidates.length > 1 ? "s" : ""}.`);
3793
+ if (status === "pending") {
3794
+ console.log(`Promote: flair rem promote <id> --rationale "<why>" --to (soul|memory)`);
3795
+ console.log(`Reject: flair rem reject <id> --reason "<why>"`);
3796
+ }
3797
+ }
3798
+ catch (err) {
3799
+ console.error(`Error: ${err.message}`);
3800
+ process.exit(1);
3801
+ }
3802
+ });
3803
+ // ─── flair rem promote / reject helpers ──────────────────────────────────────
3804
+ // Pure validators extracted for testability. The action callbacks below thread
3805
+ // these through process.exit on failure; the helpers themselves are
3806
+ // side-effect-free.
3807
+ export function validatePromoteOpts(opts) {
3808
+ if (!opts.rationale || !opts.rationale.trim()) {
3809
+ return "--rationale is required (per spec § 5: no rubber-stamp)";
3810
+ }
3811
+ if (!opts.to || (opts.to !== "soul" && opts.to !== "memory")) {
3812
+ return "--to must be 'soul' or 'memory'";
3813
+ }
3814
+ if (opts.to === "soul" && (!opts.key || !opts.key.trim())) {
3815
+ return "--key is required when --to=soul (gives the Soul entry a meaningful identifier)";
3816
+ }
3817
+ return null;
3818
+ }
3819
+ export function validateRejectOpts(opts) {
3820
+ if (!opts.reason || !opts.reason.trim()) {
3821
+ return "--reason is required";
3822
+ }
3823
+ return null;
3824
+ }
3825
+ /**
3826
+ * Decide whether a promote/reject action can proceed against a candidate's
3827
+ * current state, and what message to surface to the operator. Pure function;
3828
+ * action side effects happen in the CLI body after this returns ok.
3829
+ */
3830
+ export function decideCandidateAction(candidate, action) {
3831
+ if (!candidate)
3832
+ return { ok: false, severity: "error", message: "candidate not found" };
3833
+ const status = candidate.status;
3834
+ if (status === "promoted") {
3835
+ return action === "promote"
3836
+ ? { ok: false, severity: "error", message: `already promoted (target=${candidate.target}, reviewer=${candidate.reviewerId})` }
3837
+ : { ok: false, severity: "error", message: `already promoted; cannot reject after promotion` };
3838
+ }
3839
+ if (status === "rejected") {
3840
+ return action === "reject"
3841
+ ? { ok: false, severity: "info", message: `already rejected on ${candidate.decidedAt} by ${candidate.reviewerId}` }
3842
+ : { ok: false, severity: "error", message: `already rejected; use a fresh candidate or reset status manually` };
3843
+ }
3844
+ return { ok: true };
3845
+ }
3846
+ // ─── flair rem promote ───────────────────────────────────────────────────────
3847
+ // Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
3848
+ // or persistent Memory. Both --rationale and --to are required (spec § 5: no
3849
+ // rubber-stamp). When --to=soul, --key is also required so the resulting
3850
+ // Soul row has a meaningful identifier.
3851
+ //
3852
+ // Trust-tier policy is enforced by the caller's authentication today (1.0):
3853
+ // admin pass → any promote; agent key → can write to own Memory/Soul. Server-
3854
+ // side trust-tier enforcement (endorsed agents → memory only, never soul) is
3855
+ // scoped for slice 2b when agent-routed promotion lands. For now, the
3856
+ // human-operator workflow is the supported path.
3857
+ rem
3858
+ .command("promote")
3859
+ .description("Promote a memory candidate to Soul or persistent Memory (rationale required)")
3860
+ .argument("<candidate-id>", "MemoryCandidate id to promote")
3861
+ .option("--port <port>", "Harper HTTP port")
3862
+ .option("--rationale <text>", "Why this candidate is being promoted (required, no rubber-stamp)")
3863
+ .option("--to <target>", "Promotion target: 'soul' or 'memory'")
3864
+ .option("--key <key>", "Soul key (required when --to=soul; e.g. 'lessons', 'preference-X')")
3865
+ .option("--reviewer <id>", "Reviewer agent id (default: FLAIR_AGENT_ID or 'admin')")
3866
+ .action(async (candidateId, opts) => {
3867
+ const validationErr = validatePromoteOpts(opts);
3868
+ if (validationErr) {
3869
+ console.error(`Error: ${validationErr}`);
3870
+ process.exit(1);
3871
+ }
3872
+ const reviewerId = opts.reviewer || process.env.FLAIR_AGENT_ID || "admin";
3873
+ try {
3874
+ // Fetch the candidate
3875
+ const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
3876
+ const candidateData = (candidate && !candidate.error) ? candidate : null;
3877
+ const decision = decideCandidateAction(candidateData, "promote");
3878
+ if (!decision.ok) {
3879
+ const msg = decision.message;
3880
+ console.error(`Error: candidate ${candidateId} ${msg}`);
3881
+ process.exit(1);
3882
+ }
3883
+ const decidedAt = new Date().toISOString();
3884
+ // Write the resulting Soul or Memory entry
3885
+ if (opts.to === "memory") {
3886
+ const memId = `${candidate.agentId}-promoted-${Date.now()}`;
3887
+ const memWrite = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, {
3888
+ id: memId,
3889
+ agentId: candidate.agentId,
3890
+ content: candidate.claim,
3891
+ durability: "persistent",
3892
+ tags: ["nightly-rem-promoted", `from:${candidateId}`],
3893
+ derivedFrom: candidate.sourceMemoryIds ?? [],
3894
+ promotionStatus: "approved",
3895
+ promotedAt: decidedAt,
3896
+ promotedBy: reviewerId,
3897
+ createdAt: decidedAt,
3898
+ });
3899
+ if (memWrite?.error) {
3900
+ console.error(`Error writing Memory: ${memWrite.error}`);
3901
+ process.exit(1);
3902
+ }
3903
+ console.log(`✅ Wrote Memory ${memId} (durability=persistent)`);
3904
+ }
3905
+ else {
3906
+ // soul
3907
+ const soulId = `${candidate.agentId}-${opts.key}`;
3908
+ const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
3909
+ id: soulId,
3910
+ agentId: candidate.agentId,
3911
+ key: opts.key,
3912
+ value: candidate.claim,
3913
+ priority: "standard",
3914
+ durability: "persistent",
3915
+ createdAt: decidedAt,
3916
+ updatedAt: decidedAt,
1967
3917
  });
1968
- if (reflect.error) {
1969
- console.error(`Reflection error: ${reflect.error}`);
3918
+ if (soulWrite?.error) {
3919
+ console.error(`Error writing Soul: ${soulWrite.error}`);
1970
3920
  process.exit(1);
1971
3921
  }
1972
- console.log(` Source memories: ${reflect.count ?? 0}`);
1973
- if (reflect.suggestedTags?.length) {
1974
- console.log(` Tags: ${reflect.suggestedTags.join(", ")}`);
3922
+ console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
3923
+ }
3924
+ // Update the candidate row
3925
+ const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
3926
+ ...candidate,
3927
+ status: "promoted",
3928
+ target: opts.to,
3929
+ reviewerId,
3930
+ reviewRationale: opts.rationale,
3931
+ decidedAt,
3932
+ });
3933
+ if (upd?.error) {
3934
+ console.error(`Warning: candidate row update returned: ${upd.error}`);
3935
+ }
3936
+ console.log(`✅ Candidate ${candidateId} marked promoted → ${opts.to}, reviewer=${reviewerId}`);
3937
+ }
3938
+ catch (err) {
3939
+ console.error(`Error: ${err.message}`);
3940
+ process.exit(1);
3941
+ }
3942
+ });
3943
+ // ─── flair rem reject ────────────────────────────────────────────────────────
3944
+ // Reject a candidate with a required --reason. Per spec § 5, rejected
3945
+ // candidates retain full decision history so recurring proposals are visible
3946
+ // via the supersedes chain.
3947
+ rem
3948
+ .command("reject")
3949
+ .description("Reject a memory candidate with a required reason")
3950
+ .argument("<candidate-id>", "MemoryCandidate id to reject")
3951
+ .option("--port <port>", "Harper HTTP port")
3952
+ .option("--reason <text>", "Why this candidate is being rejected (required)")
3953
+ .option("--reviewer <id>", "Reviewer agent id (default: FLAIR_AGENT_ID or 'admin')")
3954
+ .action(async (candidateId, opts) => {
3955
+ const validationErr = validateRejectOpts(opts);
3956
+ if (validationErr) {
3957
+ console.error(`Error: ${validationErr}`);
3958
+ process.exit(1);
3959
+ }
3960
+ const reviewerId = opts.reviewer || process.env.FLAIR_AGENT_ID || "admin";
3961
+ try {
3962
+ const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
3963
+ const candidateData = (candidate && !candidate.error) ? candidate : null;
3964
+ const decision = decideCandidateAction(candidateData, "reject");
3965
+ if (!decision.ok) {
3966
+ const _d = decision;
3967
+ if (_d.severity === "info") {
3968
+ console.log(`(candidate ${candidateId} ${_d.message})`);
3969
+ return;
1975
3970
  }
1976
- console.log("\n--- Reflection Prompt ---");
1977
- console.log(reflect.prompt ?? "(no prompt returned)");
1978
- console.log("--- End Prompt ---");
3971
+ console.error(`Error: candidate ${candidateId} ${_d.message}`);
3972
+ process.exit(1);
1979
3973
  }
1980
- else {
1981
- console.log("\n[3/3] Reflection skipped no agent ID");
3974
+ const decidedAt = new Date().toISOString();
3975
+ const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
3976
+ ...candidate,
3977
+ status: "rejected",
3978
+ reviewerId,
3979
+ reviewRationale: opts.reason,
3980
+ decidedAt,
3981
+ });
3982
+ if (upd?.error) {
3983
+ console.error(`Error: candidate row update failed: ${upd.error}`);
3984
+ process.exit(1);
1982
3985
  }
1983
- console.log(`\nRestorative cycle complete.${dryRun ? " No changes applied (dry run)." : ""}`);
3986
+ console.log(`✅ Candidate ${candidateId} rejected by ${reviewerId}`);
3987
+ console.log(` Reason: ${opts.reason}`);
1984
3988
  }
1985
3989
  catch (err) {
1986
3990
  console.error(`Error: ${err.message}`);
@@ -2055,9 +4059,63 @@ function oauthDetailLines(o) {
2055
4059
  }
2056
4060
  return out;
2057
4061
  }
4062
+ // Common localhost ports a running Flair daemon might be on. Used by
4063
+ // discoverLocalFlairPort when the configured URL is unreachable, to detect
4064
+ // config-vs-daemon port drift (ops-mbdi). Order is ad-hoc — first hit wins.
4065
+ //
4066
+ // 9926: original default (long-running rockit installs predate the bump)
4067
+ // 19926: current default (DEFAULT_PORT)
4068
+ // 19925: ops-anvil VM secondary
4069
+ const LOCAL_FLAIR_PROBE_PORTS = [9926, 19926, 19925];
4070
+ export function isLocalhostUrl(url) {
4071
+ try {
4072
+ const u = new URL(url);
4073
+ // URL.hostname keeps brackets around IPv6 (e.g. "[::1]") so match both forms.
4074
+ return (u.hostname === "127.0.0.1" ||
4075
+ u.hostname === "localhost" ||
4076
+ u.hostname === "::1" ||
4077
+ u.hostname === "[::1]");
4078
+ }
4079
+ catch {
4080
+ return false;
4081
+ }
4082
+ }
4083
+ /**
4084
+ * When a configured-localhost URL is unreachable, probe a small candidate-port
4085
+ * set to detect a daemon listening on a different port (config drift). Returns
4086
+ * the first responsive port, or null if none. Excludes the original port from
4087
+ * the probe set so we don't repeat the failed call.
4088
+ *
4089
+ * Runs sequentially with a 500ms timeout per probe — typical 3-port sweep
4090
+ * completes in <1.5s on a healthy box, faster on an unhealthy one.
4091
+ */
4092
+ export async function discoverLocalFlairPort(originalUrl) {
4093
+ if (!isLocalhostUrl(originalUrl))
4094
+ return null;
4095
+ let originalPort = null;
4096
+ try {
4097
+ originalPort = Number(new URL(originalUrl).port) || null;
4098
+ }
4099
+ catch { /* ignore */ }
4100
+ for (const port of LOCAL_FLAIR_PROBE_PORTS) {
4101
+ if (port === originalPort)
4102
+ continue;
4103
+ try {
4104
+ const res = await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(500) });
4105
+ // Treat 401 (auth required) the same as 200 — the daemon is alive,
4106
+ // we just can't see /Health without admin auth. The point is to detect
4107
+ // "something is listening" not "we have full access".
4108
+ if (res.ok || res.status === 401)
4109
+ return port;
4110
+ }
4111
+ catch { /* port not listening, try next */ }
4112
+ }
4113
+ return null;
4114
+ }
2058
4115
  async function fetchHealthDetail(opts) {
2059
4116
  const port = resolveHttpPort(opts);
2060
- const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
4117
+ // --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
4118
+ const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
2061
4119
  let healthy = false;
2062
4120
  let healthData = null;
2063
4121
  try {
@@ -2114,12 +4172,23 @@ const statusCmd = program
2114
4172
  .description("Show Flair instance status, memory stats, and agent info")
2115
4173
  .option("--port <port>", "Harper HTTP port")
2116
4174
  .option("--url <url>", "Flair base URL (overrides --port)")
4175
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
2117
4176
  .option("--json", "Output as JSON")
2118
4177
  .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
2119
4178
  .action(async (opts) => {
2120
4179
  const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
4180
+ // When unreachable on a localhost URL, probe candidate ports to detect
4181
+ // config-vs-daemon port drift (ops-mbdi). Surface the actually-listening
4182
+ // port with a fix recipe — better UX than just "unreachable."
4183
+ let discoveredPort = null;
4184
+ if (!healthy && isLocalhostUrl(baseUrl)) {
4185
+ discoveredPort = await discoverLocalFlairPort(baseUrl);
4186
+ }
2121
4187
  if (opts.json) {
2122
- console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
4188
+ const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
4189
+ if (discoveredPort != null)
4190
+ out.discoveredPort = discoveredPort;
4191
+ console.log(JSON.stringify(out, null, 2));
2123
4192
  if (!healthy)
2124
4193
  process.exit(1);
2125
4194
  return;
@@ -2127,7 +4196,17 @@ const statusCmd = program
2127
4196
  if (!healthy) {
2128
4197
  console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
2129
4198
  console.log(` URL: ${baseUrl}`);
2130
- console.log(`\n Run: flair start or flair doctor`);
4199
+ if (discoveredPort != null) {
4200
+ const altUrl = `http://127.0.0.1:${discoveredPort}`;
4201
+ console.log(`\n ⚠ Found a Flair daemon listening on port ${discoveredPort} (URL: ${altUrl}).`);
4202
+ console.log(` Your config points at ${baseUrl} — drift detected.`);
4203
+ console.log(`\n Quick fix: FLAIR_URL=${altUrl} flair status`);
4204
+ console.log(` Permanent fix: edit ~/.flair/config.yaml to set port: ${discoveredPort}`);
4205
+ console.log(` Or: flair doctor (when port-drift detection lands there)`);
4206
+ }
4207
+ else {
4208
+ console.log(`\n Run: flair start or flair doctor`);
4209
+ }
2131
4210
  process.exit(1);
2132
4211
  }
2133
4212
  const uptimeSec = healthData?.uptimeSeconds;
@@ -2142,17 +4221,42 @@ const statusCmd = program
2142
4221
  const agents = healthData?.agents;
2143
4222
  const memories = healthData?.memories;
2144
4223
  const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
2145
- const hasWarn = warnings.some((w) => w.level === "warn");
2146
- // Header state-word stays "running" whenever the process is alive; the
2147
- // icon conveys health (🟢 clean / 🟡 warnings). 🔴 unreachable is already
2148
- // handled above by the `!healthy` early-exit. Decoupling state from
2149
- // health keeps the smoke-test `grep -q "running"` stable across tiers.
4224
+ // Scope warnings to the filtered agent if --agent is set
4225
+ const scopedWarnings = opts.agent && healthData?.agents?.perAgent
4226
+ ? warnings.filter((w) => {
4227
+ // Hash-fallback warnings contain agent-specific counts
4228
+ if (w.message.includes("hash-fallback")) {
4229
+ const match = w.message.match(/\b(\d+)\/(\d+) \((\d+)%\)/);
4230
+ if (match) {
4231
+ const hashCount = parseInt(match[1]);
4232
+ const totalCount = parseInt(match[2]);
4233
+ const agentRow = healthData.agents.perAgent.find((r) => r.id === opts.agent);
4234
+ if (agentRow && agentRow.hashFallback === hashCount && agentRow.memoryCount === totalCount) {
4235
+ return true;
4236
+ }
4237
+ return false;
4238
+ }
4239
+ }
4240
+ // Mixed-model warnings are fleet-wide; keep them
4241
+ if (w.message.includes("multiple embedding models"))
4242
+ return true;
4243
+ // Federation warnings are fleet-wide; keep them
4244
+ if (w.message.includes("federation"))
4245
+ return true;
4246
+ // REM warnings are fleet-wide; keep them
4247
+ if (w.message.includes("REM") || w.message.includes("nightly"))
4248
+ return true;
4249
+ // Default: keep fleet-wide warnings
4250
+ return !w.message.includes(opts.agent);
4251
+ })
4252
+ : warnings;
4253
+ const hasWarn = scopedWarnings.some((w) => w.level === "warn");
2150
4254
  const headerIcon = hasWarn ? "🟡" : "🟢";
2151
4255
  console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
2152
4256
  console.log(` URL: ${baseUrl}`);
2153
- if (warnings.length > 0) {
2154
- console.log(`\n⚠ Warnings: ${warnings.length}`);
2155
- for (const w of warnings)
4257
+ if (scopedWarnings.length > 0) {
4258
+ console.log(`\n⚠ Warnings: ${scopedWarnings.length}`);
4259
+ for (const w of scopedWarnings)
2156
4260
  console.log(` • ${w.level} ${w.message}`);
2157
4261
  }
2158
4262
  if (memories) {
@@ -2248,6 +4352,9 @@ const statusCmd = program
2248
4352
  if (f.pendingTokens > 0)
2249
4353
  console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
2250
4354
  }
4355
+ else {
4356
+ console.log("\nFederation: not configured");
4357
+ }
2251
4358
  if (healthData?.oauth) {
2252
4359
  const lines = oauthSummaryLines(healthData.oauth);
2253
4360
  for (const line of lines)
@@ -2263,6 +4370,9 @@ const statusCmd = program
2263
4370
  if (b.lastExport)
2264
4371
  console.log(` Last export: ${relativeTime(b.lastExport)}`);
2265
4372
  }
4373
+ else {
4374
+ console.log("\nBridges: none installed");
4375
+ }
2266
4376
  if (healthData?.disk) {
2267
4377
  const d = healthData.disk;
2268
4378
  console.log("\nDisk:");
@@ -2270,8 +4380,8 @@ const statusCmd = program
2270
4380
  console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
2271
4381
  }
2272
4382
  console.log("");
2273
- if (warnings.length > 0)
2274
- console.log(` Health: ⚠ ${warnings.length} warning(s)`);
4383
+ if (scopedWarnings.length > 0)
4384
+ console.log(` Health: ⚠ ${scopedWarnings.length} warning(s)`);
2275
4385
  else
2276
4386
  console.log(` Health: ✅ all checks passing`);
2277
4387
  });
@@ -2407,37 +4517,39 @@ program
2407
4517
  .description("Upgrade Flair and related packages to latest versions")
2408
4518
  .option("--check", "Only check for updates, don't install")
2409
4519
  .option("--restart", "Restart Flair after upgrade")
4520
+ .option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
2410
4521
  .action(async (opts) => {
2411
4522
  const { execSync, execFileSync } = await import("node:child_process");
2412
4523
  const checkOnly = opts.check ?? false;
4524
+ const showAll = opts.all ?? false;
2413
4525
  console.log("Checking for updates...\n");
2414
- // Per-package install probes. `npm list -g` assumed the default global
2415
- // prefix and silently mis-reported "not installed" for anyone using
2416
- // mise / fnm / nvm / volta / non-default-prefix npm — including the
2417
- // running flair binary itself, which was obviously installed. Each
2418
- // entry now has a locator that works regardless of install path:
2419
- //
2420
- // - For packages with a bin: shell out to the bin with --version
2421
- // (same PATH lookup that got them invokable in the first place).
2422
- // - For library packages: require.resolve the package.json from the
2423
- // running flair's module graph (works whether it's a sibling
2424
- // global install or a bundled dep).
2425
4526
  const packages = [
2426
4527
  {
2427
4528
  name: "@tpsdev-ai/flair",
4529
+ kind: "bin",
2428
4530
  probe: () => probeBinVersion(execFileSync, "flair"),
2429
4531
  },
2430
- {
2431
- name: "@tpsdev-ai/flair-client",
2432
- probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
2433
- },
2434
4532
  {
2435
4533
  name: "@tpsdev-ai/flair-mcp",
4534
+ kind: "bin",
2436
4535
  probe: () => probeBinVersion(execFileSync, "flair-mcp"),
2437
4536
  },
4537
+ {
4538
+ name: "@tpsdev-ai/openclaw-flair",
4539
+ kind: "openclaw-plugin",
4540
+ probe: () => probeOpenclawPluginVersion("openclaw-flair"),
4541
+ },
4542
+ {
4543
+ name: "@tpsdev-ai/flair-client",
4544
+ kind: "lib",
4545
+ probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
4546
+ transitive: true,
4547
+ },
2438
4548
  ];
2439
4549
  const findings = [];
2440
- for (const { name, probe } of packages) {
4550
+ for (const { name, probe, kind, transitive } of packages) {
4551
+ if (transitive && !showAll)
4552
+ continue;
2441
4553
  try {
2442
4554
  const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
2443
4555
  if (!res.ok)
@@ -2445,18 +4557,45 @@ program
2445
4557
  const data = await res.json();
2446
4558
  const latest = data.version ?? "unknown";
2447
4559
  const installed = probe();
2448
- const status = installed === null ? "missing" : installed === latest ? "current" : "outdated";
2449
- findings.push({ name, installed, latest, status });
2450
- const icon = status === "current" ? "✅" : status === "outdated" ? "⬆️" : "❔";
2451
- const installedLabel = installed ?? "not detected";
2452
- const suffix = status === "current" ? " (current)" : status === "missing" ? " (run: npm install -g)" : "";
4560
+ let status;
4561
+ if (installed === null) {
4562
+ // openclaw-plugin packages are optional if openclaw isn't
4563
+ // installed, don't surface a misleading "install with npm" advice.
4564
+ status = kind === "openclaw-plugin" ? "optional" : "missing";
4565
+ }
4566
+ else if (installed === latest) {
4567
+ status = "current";
4568
+ }
4569
+ else {
4570
+ status = "outdated";
4571
+ }
4572
+ findings.push({ name, installed, latest, status, kind });
4573
+ const icon = status === "current" ? "✅"
4574
+ : status === "outdated" ? "⬆️"
4575
+ : status === "optional" ? "○"
4576
+ : "❔";
4577
+ const installedLabel = installed ?? (status === "optional" ? "not installed (openclaw not detected)" : "not detected");
4578
+ const suffix = status === "current" ? " (current)"
4579
+ : status === "missing" ? " (run: npm install -g)"
4580
+ : status === "optional" ? " (install via: openclaw plugins install @tpsdev-ai/openclaw-flair)"
4581
+ : "";
2453
4582
  console.log(` ${icon} ${name}: ${installedLabel} → ${latest}${suffix}`);
2454
4583
  }
2455
4584
  catch { /* skip unavailable packages */ }
2456
4585
  }
2457
4586
  const outdated = findings.filter((f) => f.status === "outdated");
2458
4587
  const missing = findings.filter((f) => f.status === "missing");
2459
- const upgrades = outdated.map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
4588
+ // openclaw plugins upgrade through `openclaw plugins install`, not `npm
4589
+ // install -g` (npm-installed wouldn't connect to OpenClaw's gateway slot).
4590
+ // Split outdated into npm-upgradeable vs openclaw-plugin so we can use
4591
+ // the right command for each.
4592
+ const npmUpgrades = outdated
4593
+ .filter((f) => f.kind !== "openclaw-plugin")
4594
+ .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
4595
+ const openclawUpgrades = outdated
4596
+ .filter((f) => f.kind === "openclaw-plugin")
4597
+ .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
4598
+ const totalUpgrades = npmUpgrades.length + openclawUpgrades.length;
2460
4599
  if (outdated.length === 0 && missing.length === 0) {
2461
4600
  console.log("\n✅ Everything is up to date.");
2462
4601
  return;
@@ -2476,9 +4615,9 @@ program
2476
4615
  // Perform upgrade. `latest` comes from the npm registry's HTTP
2477
4616
  // response, so CodeQL (correctly) treats it as untrusted input.
2478
4617
  // Use execFileSync with argv — the spec `<name>@<version>` becomes a
2479
- // single argument to npm, no shell to inject into.
2480
- console.log(`\nUpgrading ${upgrades.length} package${upgrades.length > 1 ? "s" : ""}...\n`);
2481
- for (const { pkg, latest } of upgrades) {
4618
+ // single argument to the upgrade command, no shell to inject into.
4619
+ console.log(`\nUpgrading ${totalUpgrades} package${totalUpgrades > 1 ? "s" : ""}...\n`);
4620
+ for (const { pkg, latest } of npmUpgrades) {
2482
4621
  try {
2483
4622
  console.log(` Installing ${pkg}@${latest}...`);
2484
4623
  execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
@@ -2488,6 +4627,26 @@ program
2488
4627
  console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
2489
4628
  }
2490
4629
  }
4630
+ for (const { pkg, latest } of openclawUpgrades) {
4631
+ // OpenClaw plugins upgrade via `openclaw plugins install --force --pin`.
4632
+ // Requires openclaw on PATH; if not, surface the manual recipe instead
4633
+ // of a confusing failure.
4634
+ try {
4635
+ execFileSync("openclaw", ["--version"], { stdio: "pipe", timeout: 2000 });
4636
+ }
4637
+ catch {
4638
+ console.error(` ❌ ${pkg} upgrade skipped: openclaw not on PATH. Install manually: openclaw plugins install ${pkg}@${latest} --force --pin`);
4639
+ continue;
4640
+ }
4641
+ try {
4642
+ console.log(` Installing ${pkg}@${latest} via openclaw...`);
4643
+ execFileSync("openclaw", ["plugins", "install", `${pkg}@${latest}`, "--force", "--pin"], { stdio: "pipe" });
4644
+ console.log(` ✅ ${pkg}@${latest} installed`);
4645
+ }
4646
+ catch (err) {
4647
+ console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
4648
+ }
4649
+ }
2491
4650
  if (opts.restart) {
2492
4651
  console.log("\nRestarting Flair...");
2493
4652
  try {
@@ -2806,7 +4965,7 @@ program
2806
4965
  program
2807
4966
  .command("reembed")
2808
4967
  .description("Re-generate embeddings for memories with stale or missing model tags")
2809
- .requiredOption("--agent <id>", "Agent ID to re-embed memories for")
4968
+ .option("--agent <id>", "Agent ID to re-embed memories for (defaults to all agents with stale rows)")
2810
4969
  .option("--stale-only", "Only re-embed memories with mismatched model tag")
2811
4970
  .option("--dry-run", "Show count without modifying")
2812
4971
  .option("--port <port>", "Harper HTTP port")
@@ -2821,28 +4980,165 @@ program
2821
4980
  const batchSize = Number(opts.batchSize);
2822
4981
  const delayMs = Number(opts.delayMs);
2823
4982
  const currentModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
2824
- console.log(`Re-embedding memories for agent: ${agentId}`);
4983
+ if (agentId) {
4984
+ console.log(`Re-embedding memories for agent: ${agentId}`);
4985
+ }
4986
+ else {
4987
+ console.log("Re-embedding memories for all agents with stale rows");
4988
+ }
2825
4989
  console.log(`Current model: ${currentModel}`);
2826
4990
  if (staleOnly)
2827
4991
  console.log("Mode: stale-only (skipping up-to-date memories)");
2828
4992
  if (dryRun)
2829
4993
  console.log("Mode: dry-run (no modifications)");
2830
4994
  console.log("");
4995
+ // When no agent specified, use admin auth to fetch all memories
4996
+ if (!agentId) {
4997
+ const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
4998
+ if (!adminPass) {
4999
+ console.error("❌ Admin password required when --agent is not specified (set FLAIR_ADMIN_PASS or HDB_ADMIN_PASSWORD)");
5000
+ process.exit(1);
5001
+ }
5002
+ // Fetch every memory via the Harper ops API (search_by_conditions on the
5003
+ // Memory table) rather than POST /SemanticSearch. SemanticSearch goes
5004
+ // through the HNSW cosine index, which throws "Cosine distance comparison
5005
+ // requires an array" against rows whose stored embedding shape is
5006
+ // incompatible with the running Harper version (e.g. data written under
5007
+ // @harperfast/harper@5.0.1 read under 5.0.9). The ops API bypasses the
5008
+ // vector index — exactly what we need when the goal is to replace every
5009
+ // embedding with a freshly-computed one. Without this path, `flair
5010
+ // reembed` could not recover from the very condition it exists to fix.
5011
+ const opsPort = resolveOpsPort(opts);
5012
+ const opsAuth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
5013
+ // Harper rejects empty-value conditions ("not indexed for nulls"). Use
5014
+ // `createdAt > 1970-01-01` as the "select all" pattern: every Memory row
5015
+ // has a createdAt, the index is built, and the comparison is total.
5016
+ const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
5017
+ method: "POST",
5018
+ headers: { "Content-Type": "application/json", Authorization: opsAuth },
5019
+ body: JSON.stringify({
5020
+ operation: "search_by_conditions",
5021
+ database: "flair",
5022
+ table: "Memory",
5023
+ operator: "and",
5024
+ conditions: [{ search_attribute: "createdAt", search_type: "greater_than", search_value: "1970-01-01" }],
5025
+ get_attributes: ["*"],
5026
+ limit: 100000,
5027
+ }),
5028
+ signal: AbortSignal.timeout(60_000),
5029
+ });
5030
+ if (!searchRes.ok) {
5031
+ console.error(`❌ Failed to fetch memories via ops API: ${searchRes.status}`);
5032
+ process.exit(1);
5033
+ }
5034
+ const raw = await searchRes.json();
5035
+ const allMemories = Array.isArray(raw) ? raw : (raw?.results ?? []);
5036
+ // Group by agentId
5037
+ const byAgent = new Map();
5038
+ for (const m of allMemories) {
5039
+ if (!m.content)
5040
+ continue;
5041
+ if (staleOnly && m.embeddingModel === currentModel)
5042
+ continue;
5043
+ const agent = m.agentId || "unknown";
5044
+ if (!byAgent.has(agent))
5045
+ byAgent.set(agent, []);
5046
+ byAgent.get(agent).push(m);
5047
+ }
5048
+ // Process each agent
5049
+ let totalProcessed = 0;
5050
+ let totalErrors = 0;
5051
+ const agentCount = byAgent.size;
5052
+ let agentIndex = 0;
5053
+ for (const [agent, memories] of byAgent) {
5054
+ agentIndex++;
5055
+ console.log(`\nAgent ${agentIndex}/${agentCount}: ${agent}`);
5056
+ console.log(` Memories to re-embed: ${memories.length}`);
5057
+ const keysDir = defaultKeysDir();
5058
+ const privPath = privKeyPath(agent, keysDir);
5059
+ if (!existsSync(privPath)) {
5060
+ console.error(` ❌ Key not found: ${privPath} — skipping`);
5061
+ continue;
5062
+ }
5063
+ if (dryRun)
5064
+ continue;
5065
+ let processed = 0;
5066
+ let errors = 0;
5067
+ for (let i = 0; i < memories.length; i += batchSize) {
5068
+ const batch = memories.slice(i, i + batchSize);
5069
+ for (const memory of batch) {
5070
+ try {
5071
+ const updateRes = await authFetch(baseUrl, agent, privPath, "PUT", `/Memory/${memory.id}`, {
5072
+ id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || agent,
5073
+ });
5074
+ if (updateRes.ok)
5075
+ processed++;
5076
+ else
5077
+ errors++;
5078
+ }
5079
+ catch {
5080
+ errors++;
5081
+ }
5082
+ }
5083
+ const pct = Math.round(((i + batch.length) / memories.length) * 100);
5084
+ process.stdout.write(` \r Re-embedded ${processed}/${memories.length} (${pct}%)${errors > 0 ? ` [${errors} errors]` : ""}`);
5085
+ if (i + batchSize < memories.length)
5086
+ await new Promise(r => setTimeout(r, delayMs));
5087
+ }
5088
+ console.log(`\n ✅ Agent ${agent}: ${processed} updated, ${errors} errors`);
5089
+ totalProcessed += processed;
5090
+ totalErrors += errors;
5091
+ }
5092
+ console.log(`\n\n✅ Re-embedding complete: ${totalProcessed} updated, ${totalErrors} errors`);
5093
+ return;
5094
+ }
5095
+ // Single-agent path. Same rationale as above: fetch via the ops API
5096
+ // (search_by_value on agentId) so the vector index isn't in the read path.
5097
+ // This requires admin pass — fall back to the old SemanticSearch fetch only
5098
+ // if no admin pass is available, since that path still works on
5099
+ // version-matched data and requires only the agent's own key.
2831
5100
  const keysDir = defaultKeysDir();
2832
5101
  const privPath = privKeyPath(agentId, keysDir);
2833
5102
  if (!existsSync(privPath)) {
2834
5103
  console.error(`❌ Key not found: ${privPath}`);
2835
5104
  process.exit(1);
2836
5105
  }
2837
- const searchRes = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
2838
- agentId, limit: 10000,
2839
- });
2840
- if (!searchRes.ok) {
2841
- console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
2842
- process.exit(1);
5106
+ const adminPassSingle = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
5107
+ let allMemories = [];
5108
+ if (adminPassSingle) {
5109
+ const opsPort = resolveOpsPort(opts);
5110
+ const opsAuth = `Basic ${Buffer.from(`admin:${adminPassSingle}`).toString("base64")}`;
5111
+ const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
5112
+ method: "POST",
5113
+ headers: { "Content-Type": "application/json", Authorization: opsAuth },
5114
+ body: JSON.stringify({
5115
+ operation: "search_by_value",
5116
+ database: "flair",
5117
+ table: "Memory",
5118
+ search_attribute: "agentId",
5119
+ search_value: agentId,
5120
+ get_attributes: ["*"],
5121
+ }),
5122
+ signal: AbortSignal.timeout(60_000),
5123
+ });
5124
+ if (!searchRes.ok) {
5125
+ console.error(`❌ Failed to fetch memories via ops API: ${searchRes.status}`);
5126
+ process.exit(1);
5127
+ }
5128
+ const raw = await searchRes.json();
5129
+ allMemories = Array.isArray(raw) ? raw : (raw?.results ?? []);
5130
+ }
5131
+ else {
5132
+ const searchRes = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
5133
+ agentId, limit: 10000,
5134
+ });
5135
+ if (!searchRes.ok) {
5136
+ console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
5137
+ process.exit(1);
5138
+ }
5139
+ const data = await searchRes.json();
5140
+ allMemories = data.results ?? [];
2843
5141
  }
2844
- const data = await searchRes.json();
2845
- const allMemories = data.results ?? [];
2846
5142
  const candidates = allMemories.filter((m) => {
2847
5143
  if (!m.content)
2848
5144
  return false;
@@ -2869,7 +5165,7 @@ program
2869
5165
  for (const memory of batch) {
2870
5166
  try {
2871
5167
  const updateRes = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${memory.id}`, {
2872
- id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined,
5168
+ id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || opts.agent,
2873
5169
  });
2874
5170
  if (updateRes.ok)
2875
5171
  processed++;
@@ -2919,7 +5215,8 @@ program
2919
5215
  }
2920
5216
  }
2921
5217
  catch (e) {
2922
- console.log(` ${red("FAIL")} ${name}: ${e.message?.slice(0, 120)}`);
5218
+ const message = e instanceof Error ? e.message : String(e);
5219
+ console.log(` ${red("FAIL")} ${name}: ${message?.slice(0, 120)}`);
2923
5220
  failed++;
2924
5221
  }
2925
5222
  };
@@ -2963,6 +5260,13 @@ program
2963
5260
  process.exit(1);
2964
5261
  });
2965
5262
  // ─── flair deploy ─────────────────────────────────────────────────────────────
5263
+ // NOTE on env-var naming for `flair deploy`: the FABRIC_* env vars below intentionally
5264
+ // do NOT carry the FLAIR_ prefix that the rest of the CLI uses (FLAIR_ADMIN_PASS,
5265
+ // FLAIR_TARGET, FLAIR_PAIRING_TOKEN, etc.). FABRIC_* credentials are shared with
5266
+ // the broader TPS tooling stack — multiple tools deploy to the same Harper Fabric
5267
+ // org/cluster with the same auth, and demanding a tool-specific prefix would force
5268
+ // operators to maintain duplicated env vars. Per Kern review on PR #306: the
5269
+ // inconsistency is deliberate, document it here so the next agent doesn't "fix" it.
2966
5270
  program
2967
5271
  .command("deploy")
2968
5272
  .description("Deploy Flair as a component to a remote Harper Fabric cluster")
@@ -3287,11 +5591,164 @@ program
3287
5591
  if (issues > 0)
3288
5592
  process.exit(1);
3289
5593
  });
5594
+ // ─── flair session snapshot ──────────────────────────────────────────────────
5595
+ // Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B (ops-9wji-B / ops-ojht). Snapshot a
5596
+ // session jsonl + label metadata into a tar.gz under ~/.flair/snapshots/<agent>/sessions/.
5597
+ //
5598
+ // Three subcommands: create | list | restore. Mirrors FLAIR-NIGHTLY-REM's
5599
+ // snapshot pattern (tar.gz, 600 perms, 30-day retention enforced separately).
5600
+ //
5601
+ // Standalone-callable today; harness slices 3+4 will wire it into the
5602
+ // session-reset pipeline.
5603
+ const SNAPSHOT_ROOT = resolve(homedir(), ".flair", "snapshots");
5604
+ function sessionSnapshotDir(agent) {
5605
+ if (!/^[a-zA-Z0-9_-]+$/.test(agent))
5606
+ throw new Error(`invalid agent id: ${agent}`);
5607
+ return resolve(SNAPSHOT_ROOT, agent, "sessions");
5608
+ }
5609
+ const session = program.command("session").description("Agent session lifecycle (snapshot/restore for FLAIR-AGENT-CONTEXT-TIERS-B)");
5610
+ const sessionSnapshot = session.command("snapshot").description("Manage session snapshots (tar.gz of session jsonl + metadata)");
5611
+ sessionSnapshot
5612
+ .command("create")
5613
+ .description("Create a session snapshot tar.gz")
5614
+ .requiredOption("--agent <id>", "Agent the session belongs to")
5615
+ .requiredOption("--session-file <path>", "Path to the session jsonl to snapshot (e.g. /tmp/openclaw/openclaw-2026-05-03.log)")
5616
+ .option("--label <text>", "Label for the snapshot file (e.g. ops-ID); default: ISO timestamp")
5617
+ .action(async (opts) => {
5618
+ const sessionFile = resolve(opts.sessionFile);
5619
+ if (!existsSync(sessionFile)) {
5620
+ console.error(`Error: --session-file does not exist: ${sessionFile}`);
5621
+ process.exit(1);
5622
+ }
5623
+ const dir = sessionSnapshotDir(opts.agent);
5624
+ if (!existsSync(dir))
5625
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
5626
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
5627
+ const safeLabel = opts.label ? String(opts.label).replace(/[^a-zA-Z0-9._-]/g, "_") : ts;
5628
+ const tarballName = opts.label ? `${safeLabel}-${ts}.tar.gz` : `${ts}.tar.gz`;
5629
+ const tarballPath = resolve(dir, tarballName);
5630
+ // Write a metadata.json into a tmp dir alongside the session file for the
5631
+ // tarball, so the snapshot is self-describing.
5632
+ const meta = {
5633
+ agent: opts.agent,
5634
+ label: opts.label ?? null,
5635
+ sessionFile,
5636
+ sessionFileSize: statSync(sessionFile).size,
5637
+ createdAt: new Date().toISOString(),
5638
+ flairVersion: __pkgVersion,
5639
+ };
5640
+ const tmpDir = resolve(dir, `.tmp-${process.pid}-${Date.now()}`);
5641
+ mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
5642
+ try {
5643
+ const sessionBaseName = sessionFile.split("/").pop() ?? "session.jsonl";
5644
+ writeFileSync(resolve(tmpDir, sessionBaseName), readFileSync(sessionFile));
5645
+ writeFileSync(resolve(tmpDir, "metadata.json"), JSON.stringify(meta, null, 2) + "\n");
5646
+ await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, [sessionBaseName, "metadata.json"]);
5647
+ // Tarball perms: 600 (owner-only) — matches FLAIR-NIGHTLY-REM
5648
+ chmodSync(tarballPath, 0o600);
5649
+ }
5650
+ finally {
5651
+ rmSync(tmpDir, { recursive: true, force: true });
5652
+ }
5653
+ const size = statSync(tarballPath).size;
5654
+ console.log(tarballPath);
5655
+ console.error(` agent: ${opts.agent}`);
5656
+ console.error(` label: ${opts.label ?? "(none)"}`);
5657
+ console.error(` size: ${humanBytes(size)}`);
5658
+ });
5659
+ sessionSnapshot
5660
+ .command("list")
5661
+ .description("List session snapshots for an agent (or all agents)")
5662
+ .option("--agent <id>", "Filter to a single agent")
5663
+ .option("--json", "Output as JSON")
5664
+ .action(async (opts) => {
5665
+ if (!existsSync(SNAPSHOT_ROOT)) {
5666
+ if (opts.json) {
5667
+ console.log("[]");
5668
+ return;
5669
+ }
5670
+ console.log("(no snapshots — ~/.flair/snapshots/ does not exist yet)");
5671
+ return;
5672
+ }
5673
+ const { readdirSync } = require("node:fs");
5674
+ const rows = [];
5675
+ const agents = opts.agent ? [opts.agent] : readdirSync(SNAPSHOT_ROOT).filter((d) => {
5676
+ try {
5677
+ return statSync(resolve(SNAPSHOT_ROOT, d)).isDirectory();
5678
+ }
5679
+ catch {
5680
+ return false;
5681
+ }
5682
+ });
5683
+ for (const a of agents) {
5684
+ const dir = resolve(SNAPSHOT_ROOT, a, "sessions");
5685
+ if (!existsSync(dir))
5686
+ continue;
5687
+ for (const f of readdirSync(dir)) {
5688
+ if (!f.endsWith(".tar.gz"))
5689
+ continue;
5690
+ const p = resolve(dir, f);
5691
+ const s = statSync(p);
5692
+ rows.push({ agent: a, file: f, path: p, size: s.size, mtime: s.mtime.toISOString() });
5693
+ }
5694
+ }
5695
+ rows.sort((a, b) => b.mtime.localeCompare(a.mtime));
5696
+ if (opts.json) {
5697
+ console.log(JSON.stringify(rows, null, 2));
5698
+ return;
5699
+ }
5700
+ if (rows.length === 0) {
5701
+ console.log("(no snapshots)");
5702
+ return;
5703
+ }
5704
+ const agentW = Math.max(5, ...rows.map((r) => r.agent.length));
5705
+ const fileW = Math.max(20, ...rows.map((r) => r.file.length));
5706
+ console.log(` ${"agent".padEnd(agentW)} ${"file".padEnd(fileW)} size age`);
5707
+ for (const r of rows) {
5708
+ console.log(` ${r.agent.padEnd(agentW)} ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
5709
+ }
5710
+ console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
5711
+ });
5712
+ sessionSnapshot
5713
+ .command("restore")
5714
+ .description("Extract a session snapshot to a target directory")
5715
+ .requiredOption("--snapshot <path>", "Path to the .tar.gz snapshot")
5716
+ .option("--target <dir>", "Directory to extract into (default: <snapshot>.restored next to the snapshot)")
5717
+ .option("--dry-run", "List the snapshot's contents without extracting")
5718
+ .action(async (opts) => {
5719
+ const snapshotPath = resolve(opts.snapshot);
5720
+ if (!existsSync(snapshotPath)) {
5721
+ console.error(`Error: snapshot does not exist: ${snapshotPath}`);
5722
+ process.exit(1);
5723
+ }
5724
+ if (opts.dryRun) {
5725
+ console.log("(dry-run) snapshot contents:");
5726
+ const entries = [];
5727
+ await tarList({ file: snapshotPath, onReadEntry: (entry) => entries.push(` ${entry.path} (${humanBytes(entry.size ?? 0)})`) });
5728
+ for (const e of entries)
5729
+ console.log(e);
5730
+ return;
5731
+ }
5732
+ const targetDir = opts.target
5733
+ ? resolve(opts.target)
5734
+ : `${snapshotPath}.restored`;
5735
+ if (existsSync(targetDir)) {
5736
+ console.error(`Error: target directory already exists: ${targetDir}`);
5737
+ console.error(` Pass --target <new-path> or remove the existing dir.`);
5738
+ process.exit(1);
5739
+ }
5740
+ mkdirSync(targetDir, { recursive: true, mode: 0o700 });
5741
+ await tarExtract({ file: snapshotPath, cwd: targetDir });
5742
+ console.log(targetDir);
5743
+ console.error(` extracted to: ${targetDir}`);
5744
+ });
3290
5745
  // ─── Memory and Soul commands ────────────────────────────────────────────────
3291
5746
  const memory = program.command("memory").description("Manage agent memories");
3292
5747
  memory.command("add [content]").requiredOption("--agent <id>")
3293
5748
  .option("--content <text>", "memory content (alias for positional arg)")
3294
5749
  .option("--durability <d>", "standard").option("--tags <csv>")
5750
+ .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
5751
+ .option("--subject <text>", "one-line title / entity this memory is about")
3295
5752
  .action(async (contentArg, opts) => {
3296
5753
  const content = contentArg ?? opts.content;
3297
5754
  if (!content) {
@@ -3299,13 +5756,95 @@ memory.command("add [content]").requiredOption("--agent <id>")
3299
5756
  process.exit(1);
3300
5757
  }
3301
5758
  const memId = `${opts.agent}-${Date.now()}`;
3302
- const out = await api("PUT", `/Memory/${memId}`, {
5759
+ const body = {
3303
5760
  id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
3304
5761
  tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
3305
5762
  type: "memory", createdAt: new Date().toISOString(),
3306
- });
5763
+ };
5764
+ if (opts.summary)
5765
+ body.summary = opts.summary;
5766
+ if (opts.subject)
5767
+ body.subject = opts.subject;
5768
+ const out = await api("PUT", `/Memory/${memId}`, body);
3307
5769
  console.log(JSON.stringify(out, null, 2));
3308
5770
  });
5771
+ // ─── flair memory write-task-summary ────────────────────────────────────────
5772
+ // Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B (ops-9wji-B / ops-3xyd). Standalone
5773
+ // helper that any agent harness (or a manual operator) can invoke at task
5774
+ // close to capture a structured task summary as a persistent Memory row
5775
+ // before resetting the session.
5776
+ //
5777
+ // The shape of this row matters: tags=['task-summary','auto-on-reset'] +
5778
+ // subject='task:<beads-id>' + summary populated. Slice 3+4 (harness
5779
+ // integrations) will call this as part of the reset pipeline; slice 5+6
5780
+ // (operator surfaces) will surface promote/restore controls. Today, this
5781
+ // command is independently useful — operator can capture a manual summary
5782
+ // at any time.
5783
+ //
5784
+ // Returns the memory id on stdout (single line, parseable) so the harness
5785
+ // can plumb it into the next-dispatch system message.
5786
+ memory.command("write-task-summary")
5787
+ .description("Capture a structured task summary as a persistent Memory row (used by session-reset harness; standalone-callable by operators)")
5788
+ .requiredOption("--agent <id>", "Agent the summary belongs to")
5789
+ .requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about (e.g. ops-3xyd)")
5790
+ .requiredOption("--outcome <s>", "Outcome of the task: merged | rejected | abandoned")
5791
+ .option("--summary <text>", "Multi-sentence dense compression (populates Memory.summary; will be the agent's read-time view)")
5792
+ .option("--files-touched <csv>", "Comma-separated list of files touched during the task (becomes part of content)")
5793
+ .option("--lessons <text>", "Lessons learned during the task (becomes part of content)")
5794
+ .option("--derived-from <csv>", "Comma-separated list of source Memory IDs this summary was distilled from")
5795
+ .action(async (opts) => {
5796
+ const validOutcomes = new Set(["merged", "rejected", "abandoned"]);
5797
+ if (!validOutcomes.has(opts.outcome)) {
5798
+ console.error(`Error: --outcome must be one of: merged, rejected, abandoned (got: ${opts.outcome})`);
5799
+ process.exit(1);
5800
+ }
5801
+ if (!opts.summary && !opts.lessons && !opts.filesTouched) {
5802
+ console.error("Error: at least one of --summary, --lessons, --files-touched is required (otherwise the summary has no content)");
5803
+ process.exit(1);
5804
+ }
5805
+ // Build the structured content block. Format chosen to be parseable + readable
5806
+ // — the agent reads it back on bootstrap of the next session.
5807
+ const lines = [];
5808
+ lines.push(`task: ${opts.beads}`);
5809
+ lines.push(`outcome: ${opts.outcome}`);
5810
+ if (opts.filesTouched)
5811
+ lines.push(`files: ${opts.filesTouched}`);
5812
+ if (opts.lessons) {
5813
+ lines.push("");
5814
+ lines.push("lessons:");
5815
+ lines.push(opts.lessons);
5816
+ }
5817
+ if (opts.summary) {
5818
+ lines.push("");
5819
+ lines.push("summary:");
5820
+ lines.push(opts.summary);
5821
+ }
5822
+ const content = lines.join("\n");
5823
+ const memId = `${opts.agent}-task-${opts.beads}-${Date.now()}`;
5824
+ const body = {
5825
+ id: memId,
5826
+ agentId: opts.agent,
5827
+ content,
5828
+ durability: "persistent",
5829
+ tags: ["task-summary", "auto-on-reset"],
5830
+ subject: `task:${opts.beads}`,
5831
+ type: "task-summary",
5832
+ createdAt: new Date().toISOString(),
5833
+ };
5834
+ if (opts.summary)
5835
+ body.summary = opts.summary;
5836
+ if (opts.derivedFrom) {
5837
+ body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
5838
+ }
5839
+ const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body);
5840
+ if (out?.error) {
5841
+ console.error(`Error writing task summary: ${out.error}`);
5842
+ process.exit(1);
5843
+ }
5844
+ // Print just the memory id on stdout so the harness can capture it
5845
+ // without parsing a JSON blob.
5846
+ console.log(memId);
5847
+ });
3309
5848
  memory.command("search [query]").requiredOption("--agent <id>")
3310
5849
  .option("--q <query>", "search query (alias for positional arg)")
3311
5850
  .option("--limit <n>", "Max results", "5")
@@ -3411,6 +5950,20 @@ program
3411
5950
  }
3412
5951
  });
3413
5952
  // ─── flair bootstrap ─────────────────────────────────────────────────────────
5953
+ //
5954
+ // `flair bootstrap` prints agent context (soul, memories) and a structured budget
5955
+ // footer summarizing token usage and memory inclusion/truncation. The footer is
5956
+ // parseable for downstream agents to react to budget pressure.
5957
+ //
5958
+ // Budget footer format (printed to stderr):
5959
+ // [budget: <used>/<max> tokens, <included> included, <truncated> truncated]
5960
+ //
5961
+ // Fields:
5962
+ // - tokens: estimated tokens used / max budget
5963
+ // - included: number of memories/soul entries included in context
5964
+ // - truncated: number of memories excluded due to token budget
5965
+ //
5966
+ // When truncated &gt; 0, the agent should consider asking for more context or reducing scope.
3414
5967
  program
3415
5968
  .command("bootstrap")
3416
5969
  .description("Cold-start context: get soul + recent memories as formatted text")
@@ -3444,6 +5997,12 @@ program
3444
5997
  console.error("No context available.");
3445
5998
  process.exit(1);
3446
5999
  }
6000
+ // Print budget footer to stderr (parseable, won't interfere with context output)
6001
+ const tokensUsed = result.tokenEstimate ?? 0;
6002
+ const maxTokens = parseInt(opts.maxTokens, 10);
6003
+ const included = result.memoriesIncluded ?? 0;
6004
+ const truncated = result.memoriesTruncated ?? 0;
6005
+ console.error(`[budget: ${tokensUsed}/${maxTokens} tokens, ${included} included, ${truncated} truncated]`);
3447
6006
  }
3448
6007
  catch (err) {
3449
6008
  console.error(`Bootstrap failed: ${err.message}`);
@@ -3539,6 +6098,7 @@ bridge
3539
6098
  .option("--port <port>", "Harper HTTP port")
3540
6099
  .option("--url <url>", "Flair base URL (overrides --port)")
3541
6100
  .option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
6101
+ .option("--source <path>", "Source directory (for directory-based imports like markdown)")
3542
6102
  .action(async (name, srcArg, opts) => {
3543
6103
  const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
3544
6104
  const cwd = opts.cwd ?? srcArg ?? process.cwd();
@@ -4302,7 +6862,7 @@ program
4302
6862
  };
4303
6863
  const rawOutputPath = opts.output ?? join(homedir(), ".flair", "exports", `${agentId}-${Date.now()}.json`);
4304
6864
  // Canonicalize to prevent path traversal (e.g. ../../etc/passwd)
4305
- const outputPath = resolvePath(rawOutputPath);
6865
+ const outputPath = resolve(rawOutputPath);
4306
6866
  mkdirSync(join(outputPath, ".."), { recursive: true });
4307
6867
  const fileMode = privateKey ? 0o600 : 0o644;
4308
6868
  writeFileSync(outputPath, JSON.stringify(exportData, null, 2), { mode: fileMode });
@@ -4441,9 +7001,264 @@ program
4441
7001
  console.log(`Souls: ${(data.souls ?? []).length}`);
4442
7002
  }
4443
7003
  });
7004
+ // ─── flair migrate-harness-memory ───────────────────────────────────────────
7005
+ /** Resolve the memory directory path for a target harness. */
7006
+ function resolveMemoryDir(target, agentId) {
7007
+ switch (target) {
7008
+ case "claude-code": {
7009
+ const cwd = process.cwd();
7010
+ const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
7011
+ const memoryDir = join(homedir(), ".claude", "projects", encodedCwd, "memory");
7012
+ const resolved = resolve(memoryDir);
7013
+ const expectedRoot = join(homedir(), ".claude", "projects");
7014
+ if (!resolved.startsWith(expectedRoot + sep)) {
7015
+ throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
7016
+ }
7017
+ return resolved;
7018
+ }
7019
+ case "openclaw": {
7020
+ const cwd = process.cwd();
7021
+ const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
7022
+ const memoryDir = join(homedir(), ".openclaw", "projects", encodedCwd, "memory");
7023
+ const resolved = resolve(memoryDir);
7024
+ const expectedRoot = join(homedir(), ".openclaw", "projects");
7025
+ if (!resolved.startsWith(expectedRoot + sep)) {
7026
+ throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
7027
+ }
7028
+ return resolved;
7029
+ }
7030
+ case "pi": {
7031
+ const cwd = process.cwd();
7032
+ const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
7033
+ const memoryDir = join(homedir(), ".pi", "projects", encodedCwd, "memory");
7034
+ const resolved = resolve(memoryDir);
7035
+ const expectedRoot = join(homedir(), ".pi", "projects");
7036
+ if (!resolved.startsWith(expectedRoot + sep)) {
7037
+ throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
7038
+ }
7039
+ return resolved;
7040
+ }
7041
+ default:
7042
+ throw new Error(`Unknown target: ${target}. Valid: claude-code, openclaw, pi`);
7043
+ }
7044
+ }
7045
+ /** Parse a memory file with YAML frontmatter. */
7046
+ function parseMemoryFile(filePath) {
7047
+ const raw = readFileSync(filePath, "utf-8");
7048
+ const lines = raw.split("\n");
7049
+ if (!lines[0].startsWith("---")) {
7050
+ return { meta: {}, body: raw };
7051
+ }
7052
+ let endIdx = 1;
7053
+ while (endIdx < lines.length && !lines[endIdx].startsWith("---")) {
7054
+ endIdx++;
7055
+ }
7056
+ const frontmatterLines = lines.slice(1, endIdx);
7057
+ const bodyLines = lines.slice(endIdx + 1);
7058
+ const yamlContent = frontmatterLines.join("\n");
7059
+ const meta = parseYaml(yamlContent) || {};
7060
+ return {
7061
+ meta: {
7062
+ name: meta.name,
7063
+ description: meta.description,
7064
+ type: meta.type,
7065
+ tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []),
7066
+ },
7067
+ body: bodyLines.join("\n").trim(),
7068
+ };
7069
+ }
7070
+ /** Map memory type to durability. */
7071
+ function mapDurability(type) {
7072
+ switch (type) {
7073
+ case "feedback":
7074
+ case "reference":
7075
+ return "permanent";
7076
+ case "project":
7077
+ case "user":
7078
+ return "persistent";
7079
+ default:
7080
+ return "standard";
7081
+ }
7082
+ }
7083
+ /** Extract keywords from filename for tags. */
7084
+ function extractKeywordsFromFilename(filename) {
7085
+ const base = filename.replace(/\.md$/, "");
7086
+ const withoutType = base.replace(/^(feedback|project|reference|user)_/, "");
7087
+ const parts = withoutType.split(/[_-]+/).map(p => p.toLowerCase());
7088
+ const stopwords = new Set(["the", "and", "for", "with", "about", "on", "in", "to", "of", "a", "an"]);
7089
+ return parts.filter(p => p.length > 2 && !stopwords.has(p));
7090
+ }
7091
+ program
7092
+ .command("migrate-harness-memory")
7093
+ .description("Migrate harness-local memories to Flair")
7094
+ .requiredOption("--target <target>", "Target harness: claude-code, openclaw, pi")
7095
+ .requiredOption("--agent <id>", "Agent ID to write memories under")
7096
+ .option("--dry-run", "Show what would be migrated without writing")
7097
+ .action(async (opts) => {
7098
+ const target = opts.target;
7099
+ const agentId = opts.agent;
7100
+ const dryRun = !!opts.dryRun;
7101
+ let memoryDir;
7102
+ try {
7103
+ memoryDir = resolveMemoryDir(target, agentId);
7104
+ }
7105
+ catch (e) {
7106
+ console.error(`Error resolving memory directory: ${e.message}`);
7107
+ process.exit(1);
7108
+ }
7109
+ if (!existsSync(memoryDir)) {
7110
+ console.error(`Error: Memory directory not found: ${memoryDir}`);
7111
+ process.exit(1);
7112
+ }
7113
+ const migratedDir = join(memoryDir, ".migrated");
7114
+ if (!dryRun) {
7115
+ mkdirSync(migratedDir, { recursive: true, mode: 0o700 });
7116
+ }
7117
+ console.log(`Migrating memories from ${memoryDir} to Flair (agentId=${agentId})`);
7118
+ if (dryRun)
7119
+ console.log(" (DRY RUN - no files will be modified)");
7120
+ const files = readdirSync(memoryDir, { withFileTypes: true })
7121
+ .filter(d => d.isFile() && d.name.endsWith(".md") && d.name !== "MEMORY.md")
7122
+ .map(d => d.name)
7123
+ .sort();
7124
+ if (files.length === 0) {
7125
+ console.log("No memory files found.");
7126
+ return;
7127
+ }
7128
+ console.log(`Found ${files.length} memory file(s) to migrate.`);
7129
+ let successCount = 0;
7130
+ let skipCount = 0;
7131
+ let failCount = 0;
7132
+ for (const filename of files) {
7133
+ const sourcePath = join(memoryDir, filename);
7134
+ const migratedPath = join(migratedDir, filename);
7135
+ if (existsSync(migratedPath)) {
7136
+ console.log(` ${filename}: already migrated, skipping`);
7137
+ skipCount++;
7138
+ continue;
7139
+ }
7140
+ console.log(` Processing: ${filename}...`);
7141
+ let parsed;
7142
+ try {
7143
+ parsed = parseMemoryFile(sourcePath);
7144
+ }
7145
+ catch (e) {
7146
+ console.error(` Failed to parse: ${e.message}`);
7147
+ failCount++;
7148
+ continue;
7149
+ }
7150
+ let type = parsed.meta.type;
7151
+ if (!type) {
7152
+ if (filename.startsWith("feedback_"))
7153
+ type = "feedback";
7154
+ else if (filename.startsWith("project_"))
7155
+ type = "project";
7156
+ else if (filename.startsWith("reference_"))
7157
+ type = "reference";
7158
+ else
7159
+ type = "session";
7160
+ }
7161
+ const durability = mapDurability(type);
7162
+ const tags = [...(parsed.meta.tags || [])];
7163
+ const filenameTags = extractKeywordsFromFilename(filename);
7164
+ for (const t of filenameTags) {
7165
+ if (!tags.includes(t))
7166
+ tags.push(t);
7167
+ }
7168
+ tags.push(type);
7169
+ const content = parsed.body;
7170
+ console.log(` Type: ${type}, Durability: ${durability}`);
7171
+ console.log(` Tags: ${tags.join(", ")}`);
7172
+ console.log(` Content preview: ${content.slice(0, 100)}${content.length > 100 ? "..." : ""}`);
7173
+ if (!dryRun) {
7174
+ console.log(` Writing to Flair...`);
7175
+ try {
7176
+ const DEFAULT_PORT = 19926;
7177
+ const httpUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
7178
+ const agentKeyId = `${agentId}.key`;
7179
+ const keysDir = join(homedir(), ".flair", "keys");
7180
+ const keyPath = join(keysDir, agentKeyId);
7181
+ let authSuccess = false;
7182
+ if (existsSync(keyPath)) {
7183
+ const memoryId = `${agentId}-${randomUUID()}`;
7184
+ const body = {
7185
+ id: memoryId,
7186
+ agentId: agentId,
7187
+ content: content,
7188
+ type: type,
7189
+ durability: durability,
7190
+ tags: tags,
7191
+ createdAt: new Date().toISOString(),
7192
+ };
7193
+ const memoryPath = `/Memory/${memoryId}`;
7194
+ const res = await authFetch(httpUrl, agentId, keyPath, "PUT", memoryPath, body);
7195
+ if (res.ok) {
7196
+ authSuccess = true;
7197
+ console.log(` Write to Flair successful (Ed25519 auth)`);
7198
+ }
7199
+ }
7200
+ if (!authSuccess) {
7201
+ const adminPass = process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD || "";
7202
+ if (adminPass) {
7203
+ const memoryId = `${agentId}-${randomUUID()}`;
7204
+ const body = {
7205
+ id: memoryId,
7206
+ agentId: agentId,
7207
+ content: content,
7208
+ type: type,
7209
+ durability: durability,
7210
+ tags: tags,
7211
+ createdAt: new Date().toISOString(),
7212
+ };
7213
+ const memoryPath = `/Memory/${memoryId}`;
7214
+ const auth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
7215
+ const res = await fetch(`${httpUrl}${memoryPath}`, {
7216
+ method: "PUT",
7217
+ headers: {
7218
+ Authorization: auth,
7219
+ "Content-Type": "application/json",
7220
+ },
7221
+ body: JSON.stringify(body),
7222
+ signal: AbortSignal.timeout(10000),
7223
+ });
7224
+ if (res.ok) {
7225
+ console.log(` Write to Flair successful (Basic auth)`);
7226
+ }
7227
+ else {
7228
+ const text = await res.text();
7229
+ throw new Error(`HTTP ${res.status}: ${text}`);
7230
+ }
7231
+ }
7232
+ else {
7233
+ throw new Error("No authentication method available");
7234
+ }
7235
+ }
7236
+ renameSync(sourcePath, migratedPath);
7237
+ console.log(` Moved to .migrated/${filename}`);
7238
+ }
7239
+ catch (e) {
7240
+ console.error(` Failed: ${e.message}`);
7241
+ failCount++;
7242
+ continue;
7243
+ }
7244
+ }
7245
+ else {
7246
+ console.log(` [dry-run] Would write to Flair and move to .migrated/${filename}`);
7247
+ }
7248
+ successCount++;
7249
+ }
7250
+ console.log(`\nMigration complete:`);
7251
+ console.log(` Processed: ${files.length}`);
7252
+ console.log(` Successful: ${successCount}`);
7253
+ console.log(` Skipped: ${skipCount}`);
7254
+ console.log(` Failed: ${failCount}`);
7255
+ if (failCount > 0) {
7256
+ process.exit(1);
7257
+ }
7258
+ });
4444
7259
  // Run CLI only when this is the entry point (not when imported for testing)
4445
7260
  if (import.meta.main) {
4446
7261
  await program.parseAsync();
4447
7262
  }
4448
7263
  // ─── Exported for testing ─────────────────────────────────────────────────────
4449
- export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, signRequestBody, b64, b64url, program, };
7264
+ export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };