@tpsdev-ai/flair 0.6.1 → 0.7.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, } 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 } 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,225 @@ 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
+ }
327
684
  // ─── Upgrade presence probes ──────────────────────────────────────────────────
328
685
  //
329
686
  // `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
@@ -515,7 +872,8 @@ async function runSoulWizard(agentId) {
515
872
  }
516
873
  }
517
874
  catch (err) {
518
- console.log(`\n Couldn't parse JSON (${err.message}). Falling back to custom prompts.`);
875
+ const message = err instanceof Error ? err.message : String(err);
876
+ console.log(`\n Couldn't parse JSON (${message}). Falling back to custom prompts.`);
519
877
  entries = await customSoulPrompts(ask);
520
878
  }
521
879
  }
@@ -542,24 +900,257 @@ program.name("flair").version(__pkgVersion, "-v, --version");
542
900
  // ─── flair init ──────────────────────────────────────────────────────────────
543
901
  program
544
902
  .command("init")
545
- .description("Bootstrap a local Flair (Harper) instance for an agent")
546
- .option("--agent-id <id>", "Agent ID to register", "local")
903
+ .description("Bootstrap a Flair (Harper) instance for an agent")
904
+ .option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
547
905
  .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
548
906
  .option("--ops-port <port>", "Harper operations API port")
549
907
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
908
+ .option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
550
909
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
551
910
  .option("--data-dir <dir>", "Harper data directory")
552
911
  .option("--skip-start", "Skip Harper startup (assume already running)")
553
912
  .option("--skip-soul", "Skip interactive personality setup")
913
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
914
+ .option("--remote", "When used with --target, init as hub for remote federation")
915
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
916
+ .option("--force", "Skip confirmation prompt for remote writes (required with --target)")
917
+ .option("--cluster-admin-user <user>", "Harper cluster admin username (env: FLAIR_CLUSTER_ADMIN_USER)")
918
+ .option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
919
+ .option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
554
920
  .action(async (opts) => {
555
921
  const agentId = opts.agentId;
922
+ const target = resolveTarget(opts);
923
+ const opsTarget = resolveOpsTarget(opts);
924
+ // ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
925
+ if (target || opsTarget) {
926
+ // When -only- --ops-target is provided, attempt to derive REST URL
927
+ if (!target && opsTarget) {
928
+ console.error("Error: --ops-target requires --target as well. Pass --target <rest-url> for the REST API surface.");
929
+ console.error(" Currently only explicit --ops-target + --target combination is supported.");
930
+ process.exit(1);
931
+ }
932
+ const baseUrl = target.replace(/\/$/, "");
933
+ // --ops-target overrides derivation; otherwise derive from --target
934
+ const opsUrl = opsTarget ? opsTarget.replace(/\/$/, "") : resolveOpsUrlFromTarget(baseUrl);
935
+ // Check for cluster-admin provisioning (new atomic flow)
936
+ const clusterAdminUser = opts.clusterAdminUser || process.env.FLAIR_CLUSTER_ADMIN_USER;
937
+ const clusterAdminPass = opts.clusterAdminPass || process.env.FLAIR_CLUSTER_ADMIN_PASS;
938
+ let flairAdminPass = opts.flairAdminPass || process.env.FLAIR_ADMIN_PASS;
939
+ let didProvision = false;
940
+ if (clusterAdminUser && clusterAdminPass) {
941
+ // ── New provisioning path: deploy Flair to Fabric, wait, provision super_user ──
942
+ if (!opts.force) {
943
+ console.error("Error: --force is required with --target/--ops-target (remote init provisions a live Fabric instance)");
944
+ console.error(" Pass --force to confirm this is intended.");
945
+ process.exit(1);
946
+ }
947
+ // Generate flair admin pass if not provided
948
+ if (!flairAdminPass) {
949
+ flairAdminPass = randomBytes(24).toString("base64url");
950
+ }
951
+ // Write the flair admin pass to secrets directory
952
+ const secretsDir = join(homedir(), ".tps", "secrets");
953
+ mkdirSync(secretsDir, { recursive: true });
954
+ const secretPath = join(secretsDir, "flair-fabric-hdb");
955
+ writeFileSync(secretPath, flairAdminPass + "\n", { mode: 0o600 });
956
+ console.log(`Admin password written to ${secretPath}`);
957
+ // Atomic provisioning: deploy + wait + provision user
958
+ await provisionFabric(baseUrl, opsUrl, clusterAdminUser, clusterAdminPass, flairAdminPass);
959
+ didProvision = true;
960
+ }
961
+ else {
962
+ // ── Existing behavior: --admin-pass required for already-running Flair ──
963
+ if (!opts.adminPass) {
964
+ console.error("Error: --admin-pass is required with --target/--ops-target (remote init without --cluster-admin-user/--cluster-admin-pass)");
965
+ console.error(" Use --cluster-admin-user and --cluster-admin-pass for automated Fabric provisioning.");
966
+ process.exit(1);
967
+ }
968
+ if (!opts.force) {
969
+ const displayTarget = target || opsTarget;
970
+ console.error(`Error: --force is required with --target/--ops-target. Remote init writes to a live Flair instance at ${displayTarget}.`);
971
+ console.error(" Pass --force to confirm this is intended.");
972
+ process.exit(1);
973
+ }
974
+ flairAdminPass = opts.adminPass;
975
+ }
976
+ const adminUser = DEFAULT_ADMIN_USER;
977
+ const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
978
+ const role = opts.remote ? "hub" : undefined;
979
+ // Generate or reuse keypair (only if --agent-id provided, or --remote needs
980
+ // a public key for the FederationInstance row)
981
+ let pubKeyB64url;
982
+ let privPath;
983
+ let instanceId;
984
+ if (agentId || role) {
985
+ const keysDir = opts.keysDir ?? defaultKeysDir();
986
+ mkdirSync(keysDir, { recursive: true });
987
+ if (agentId) {
988
+ privPath = privKeyPath(agentId, keysDir);
989
+ const pubPath = pubKeyPath(agentId, keysDir);
990
+ if (existsSync(privPath)) {
991
+ console.log(`Reusing existing key: ${privPath}`);
992
+ const seed = new Uint8Array(readFileSync(privPath));
993
+ const kp = nacl.sign.keyPair.fromSeed(seed);
994
+ pubKeyB64url = b64url(kp.publicKey);
995
+ }
996
+ else {
997
+ console.log("Generating Ed25519 keypair...");
998
+ const kp = nacl.sign.keyPair();
999
+ const seed = kp.secretKey.slice(0, 32);
1000
+ writeFileSync(privPath, Buffer.from(seed));
1001
+ chmodSync(privPath, 0o600);
1002
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
1003
+ pubKeyB64url = b64url(kp.publicKey);
1004
+ console.log(`Keypair written: ${privPath} ✓`);
1005
+ }
1006
+ // Seed agent via remote ops API
1007
+ console.log(`Seeding agent '${agentId}' on ${baseUrl}...`);
1008
+ await seedAgentViaOpsApi(opsUrl, agentId, pubKeyB64url, adminUser, flairAdminPass);
1009
+ console.log(`Agent '${agentId}' registered on remote instance ✓`);
1010
+ }
1011
+ else {
1012
+ // No agentId -- generate throwaway keypair for FederationInstance row
1013
+ console.log("Generating federation instance keypair...");
1014
+ const kp = nacl.sign.keyPair();
1015
+ pubKeyB64url = b64url(kp.publicKey);
1016
+ }
1017
+ }
1018
+ else {
1019
+ console.log("No --agent-id provided -- skipping agent registration");
1020
+ }
1021
+ // Write FederationInstance row if --remote (hub role)
1022
+ if (role) {
1023
+ if (!pubKeyB64url) {
1024
+ const kp = nacl.sign.keyPair();
1025
+ pubKeyB64url = b64url(kp.publicKey);
1026
+ }
1027
+ instanceId = randomUUID();
1028
+ console.log(`Writing federation Instance (role=${role}) via ops API...`);
1029
+ await seedFederationInstanceViaOpsApi(opsUrl, instanceId, pubKeyB64url, role, adminUser, flairAdminPass);
1030
+ console.log(`Federation Instance created: ${instanceId} (${role}) ✓`);
1031
+ }
1032
+ // Verify connectivity
1033
+ if (didProvision) {
1034
+ // Use /FederationInstance with Basic auth (not /Health which false-401s on Fabric)
1035
+ console.log("Verifying remote connectivity...");
1036
+ const verifyRes = await fetch(`${baseUrl}/FederationInstance`, {
1037
+ headers: { Authorization: auth },
1038
+ signal: AbortSignal.timeout(5000),
1039
+ });
1040
+ if (!verifyRes.ok) {
1041
+ const body = await verifyRes.text().catch(() => "");
1042
+ console.error(`Remote verification failed (${verifyRes.status}): ${body}`);
1043
+ process.exit(1);
1044
+ }
1045
+ console.log("✓ Hub ready at " + baseUrl);
1046
+ }
1047
+ else {
1048
+ // Existing behavior: /Health check (already-running Flair)
1049
+ console.log("Verifying remote connectivity...");
1050
+ const verifyRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1051
+ if (!verifyRes.ok) {
1052
+ console.error(`Remote health check failed: ${verifyRes.status}`);
1053
+ process.exit(1);
1054
+ }
1055
+ console.log("Remote Flair instance healthy ✓");
1056
+ }
1057
+ // Print summary
1058
+ if (didProvision) {
1059
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8"));
1060
+ const flavor = pkg.version || "(unknown)";
1061
+ const displayTarget = target || opsTarget;
1062
+ console.log(`\n✓ Flair hub deployed to ${displayTarget}`);
1063
+ console.log(` Component: flair@${flavor}`);
1064
+ console.log(` Admin user: ${adminUser} (pass written to ${join(homedir(), ".tps", "secrets", "flair-fabric-hdb")})`);
1065
+ if (instanceId)
1066
+ console.log(` Instance: ${instanceId} (role=${role})`);
1067
+ console.log(` Federation: ready — run \`flair federation token\` to mint a pairing token`);
1068
+ }
1069
+ else {
1070
+ console.log(`\n✅ Remote Flair initialized`);
1071
+ if (agentId)
1072
+ console.log(` Agent ID: ${agentId}`);
1073
+ console.log(` Target: ${baseUrl}`);
1074
+ if (agentId)
1075
+ console.log(` Private key: ${privPath}`);
1076
+ if (role)
1077
+ console.log(` Role: ${role}`);
1078
+ console.log(`\n Export: FLAIR_URL=${baseUrl}`);
1079
+ }
1080
+ return;
1081
+ }
1082
+ // ── Local init (original behavior) ──
556
1083
  const httpPort = resolveHttpPort(opts);
557
1084
  const opsPort = resolveOpsPort(opts);
558
1085
  const keysDir = opts.keysDir ?? defaultKeysDir();
559
1086
  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");
1087
+ // Admin password: determine from opts, env, or generate
1088
+ // Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
1089
+ let adminPass;
1090
+ let passwordSource = "generated";
1091
+ // Warn if --admin-pass is passed inline (not from env)
1092
+ if (shouldShowInlineSecretWarning(opts.adminPass, false, new Set(["--admin-pass"]), "--admin-pass")) {
1093
+ console.error("warning: --admin-pass passed inline. Consider --admin-pass-file <path> or FLAIR_ADMIN_PASS env " +
1094
+ "to keep secrets out of shell history.");
1095
+ }
1096
+ // Read from file if provided
1097
+ if (opts.adminPassFile) {
1098
+ if (!existsSync(opts.adminPassFile)) {
1099
+ console.error(`Error: --admin-pass-file path does not exist: ${opts.adminPassFile}`);
1100
+ process.exit(1);
1101
+ }
1102
+ const fileContent = readFileSync(opts.adminPassFile, "utf-8");
1103
+ adminPass = fileContent.trim();
1104
+ if (!adminPass) {
1105
+ console.error(`Error: admin password file is empty or contains only whitespace: ${opts.adminPassFile}`);
1106
+ process.exit(1);
1107
+ }
1108
+ passwordSource = "file";
1109
+ }
1110
+ else if (process.env.FLAIR_ADMIN_PASS) {
1111
+ adminPass = process.env.FLAIR_ADMIN_PASS;
1112
+ passwordSource = "env";
1113
+ }
1114
+ else if (process.env.HDB_ADMIN_PASSWORD) {
1115
+ adminPass = process.env.HDB_ADMIN_PASSWORD;
1116
+ passwordSource = "env";
1117
+ }
1118
+ else if (opts.adminPass) {
1119
+ // Inline admin pass (deprecated)
1120
+ adminPass = opts.adminPass;
1121
+ // Don't generate - don't write to file
1122
+ passwordSource = "env"; // Treat same as env for display purposes
1123
+ }
1124
+ else {
1125
+ // Generate new password and write to file atomically
1126
+ adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
1127
+ passwordSource = "generated";
1128
+ // Atomic write: create temp file in same dir, then rename
1129
+ const flairDir = join(homedir(), ".flair");
1130
+ mkdirSync(flairDir, { recursive: true });
1131
+ const adminPassPath = join(flairDir, "admin-pass");
1132
+ const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
1133
+ const finalTempPath = join(tempPath, "admin-pass");
1134
+ try {
1135
+ writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
1136
+ renameSync(finalTempPath, adminPassPath);
1137
+ rmSync(tempPath, { recursive: true, force: true });
1138
+ }
1139
+ catch (err) {
1140
+ // Clean up temp dir on failure
1141
+ try {
1142
+ rmSync(tempPath, { recursive: true, force: true });
1143
+ }
1144
+ catch { }
1145
+ throw err;
1146
+ }
1147
+ }
562
1148
  const adminUser = DEFAULT_ADMIN_USER;
1149
+ // If we generated the password, report where it was saved
1150
+ if (passwordSource === "generated") {
1151
+ const adminPassPath = join(homedir(), ".flair", "admin-pass");
1152
+ console.log(`Admin password saved to: ${adminPassPath}`);
1153
+ }
563
1154
  // Check Node.js version
564
1155
  const major = parseInt(process.version.slice(1), 10);
565
1156
  if (major < 18)
@@ -719,125 +1310,490 @@ program
719
1310
  }
720
1311
  // Persist port to config so other commands can find this instance
721
1312
  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);
1313
+ if (agentId) {
1314
+ // Generate or reuse keypair
1315
+ mkdirSync(keysDir, { recursive: true });
1316
+ const privPath = privKeyPath(agentId, keysDir);
1317
+ const pubPath = pubKeyPath(agentId, keysDir);
1318
+ let pubKeyB64url;
1319
+ if (existsSync(privPath)) {
1320
+ console.log(`Reusing existing key: ${privPath}`);
1321
+ const seed = new Uint8Array(readFileSync(privPath));
1322
+ const kp = nacl.sign.keyPair.fromSeed(seed);
1323
+ pubKeyB64url = b64url(kp.publicKey);
1324
+ }
1325
+ else {
1326
+ console.log("Generating Ed25519 keypair...");
1327
+ const kp = nacl.sign.keyPair();
1328
+ // Store only the 32-byte seed (first 32 bytes of secretKey)
1329
+ const seed = kp.secretKey.slice(0, 32);
1330
+ writeFileSync(privPath, Buffer.from(seed));
1331
+ chmodSync(privPath, 0o600);
1332
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
1333
+ pubKeyB64url = b64url(kp.publicKey);
1334
+ console.log(`Keypair written: ${privPath} ✓`);
1335
+ }
1336
+ // Seed agent via operations API
1337
+ console.log(`Seeding agent '${agentId}' via operations API...`);
1338
+ await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass);
1339
+ console.log(`Agent '${agentId}' registered ✓`);
1340
+ // Verify Ed25519 auth
1341
+ console.log("Verifying Ed25519 auth...");
1342
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
1343
+ const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
1344
+ if (!verifyRes.ok)
1345
+ throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
1346
+ console.log("Ed25519 auth verified ✓");
1347
+ // Output — admin password printed once, never written to disk
1348
+ console.log("\n✅ Flair initialized successfully");
1349
+ console.log(` Agent ID: ${agentId}`);
1350
+ console.log(` Flair URL: ${httpUrl}`);
1351
+ console.log(` Private key: ${privPath}`);
1352
+ // Display admin credentials when password was generated or from a file
1353
+ // Do NOT display when from env (to avoid showing the env var value)
1354
+ if (passwordSource !== "env" && !alreadyRunning) {
1355
+ const passDisplay = passwordSource === "file"
1356
+ ? opts.adminPassFile ?? "(file path)"
1357
+ : "~/.flair/admin-pass";
1358
+ console.log(`\n ┌─────────────────────────────────────────────────┐`);
1359
+ console.log(` │ Harper admin credentials (save these now): │`);
1360
+ console.log(` │ │`);
1361
+ console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
1362
+ console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
1363
+ console.log(` │ │`);
1364
+ console.log(` │ ⚠️ The password won't be shown again. │`);
1365
+ console.log(` └─────────────────────────────────────────────────┘`);
1366
+ }
1367
+ console.log(`\n Export: FLAIR_URL=${httpUrl}`);
1368
+ // ── First-run soul setup ──────────────────────────────────────────────
1369
+ // Interactive wizard to set initial personality (see runSoulWizard).
1370
+ // Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
1371
+ //
1372
+ // Non-TTY / --skip-soul used to seed placeholder text like
1373
+ // "AI assistant [default]" — it leaked into bootstrap output and
1374
+ // confused users. Now those paths leave the soul empty and nudge the
1375
+ // user toward `flair soul set` / `flair doctor` instead.
1376
+ if (!opts.skipSoul && process.stdin.isTTY) {
1377
+ const soulEntries = await runSoulWizard(agentId);
1378
+ if (soulEntries.length > 0) {
1379
+ console.log("");
1380
+ for (const [key, value] of soulEntries) {
1381
+ try {
1382
+ await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
1383
+ console.log(` ✓ soul:${key} set`);
1384
+ }
1385
+ catch (err) {
1386
+ const message = err instanceof Error ? err.message : String(err);
1387
+ console.warn(` ⚠ soul:${key} failed: ${message}`);
1388
+ }
1389
+ }
1390
+ console.log(`\n ${soulEntries.length} soul entries saved.`);
1391
+ console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
1392
+ }
1393
+ else {
1394
+ console.log(`\n No soul entries saved. Add later with:`);
1395
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
1396
+ console.log(` Or run \`flair doctor\` anytime for a nudge.`);
1397
+ }
1398
+ }
1399
+ else {
1400
+ const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
1401
+ console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
1402
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
1403
+ }
1404
+ console.log(`\n Claude Code: Add to your CLAUDE.md:`);
1405
+ console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
1406
+ // Auto-wire MCP config into ~/.claude.json if Claude Code is installed
1407
+ const claudeJsonPath = join(homedir(), ".claude.json");
1408
+ const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
1409
+ const flairMcpConfig = {
1410
+ type: "stdio",
1411
+ command: "flair-mcp",
1412
+ args: [],
1413
+ env: mcpEnv,
1414
+ };
1415
+ try {
1416
+ if (existsSync(claudeJsonPath)) {
1417
+ const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1418
+ const existing = claudeJson.mcpServers?.flair;
1419
+ if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
1420
+ console.log(`\n MCP config already set in ~/.claude.json ✓`);
1421
+ }
1422
+ else {
1423
+ claudeJson.mcpServers = claudeJson.mcpServers || {};
1424
+ claudeJson.mcpServers.flair = flairMcpConfig;
1425
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
1426
+ console.log(`\n MCP config written to ~/.claude.json ✓`);
1427
+ console.log(` Restart Claude Code to pick up the new config.`);
1428
+ }
1429
+ }
1430
+ else {
1431
+ console.log(`\n MCP config (add to ~/.claude.json):`);
1432
+ console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1433
+ }
1434
+ }
1435
+ catch {
1436
+ console.log(`\n MCP config (add manually to ~/.claude.json):`);
1437
+ console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1438
+ }
1439
+ }
1440
+ else {
1441
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
1442
+ console.log("\n✅ Flair initialized (no agent registered)");
1443
+ console.log(` Flair URL: ${httpUrl}`);
1444
+ // Display admin credentials when password was generated or from a file
1445
+ // Do NOT display when from env (to avoid showing the env var value)
1446
+ if (passwordSource !== "env" && !alreadyRunning) {
1447
+ const passDisplay = passwordSource === "file"
1448
+ ? opts.adminPassFile ?? "(file path)"
1449
+ : "~/.flair/admin-pass";
1450
+ console.log(`\n ┌─────────────────────────────────────────────────┐`);
1451
+ console.log(` │ Harper admin credentials (save these now): │`);
1452
+ console.log(` │ │`);
1453
+ console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
1454
+ console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
1455
+ console.log(` │ │`);
1456
+ console.log(` │ ⚠️ The password won't be shown again. │`);
1457
+ console.log(` └─────────────────────────────────────────────────┘`);
1458
+ }
1459
+ console.log(`\n Export: FLAIR_URL=${httpUrl}`);
1460
+ }
1461
+ });
1462
+ // ─── flair install ───────────────────────────────────────────────────────────
1463
+ program
1464
+ .command("install")
1465
+ .description("One-command Flair setup — init, agent, and MCP client wiring")
1466
+ .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
1467
+ .option("--agent <id>", "Agent ID (defaults to hostname short-form)")
1468
+ .option("--no-mcp", "Skip MCP wiring (init + agent only)")
1469
+ .option("--port <port>", "Harper HTTP port")
1470
+ .option("--ops-port <port>", "Harper operations API port")
1471
+ .option("--data-dir <dir>", "Harper data directory")
1472
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
1473
+ .option("--skip-smoke", "Skip MCP smoke test")
1474
+ .action(async (opts) => {
1475
+ const httpPort = resolveHttpPort(opts);
1476
+ const opsPort = resolveOpsPort(opts);
1477
+ const keysDir = opts.keysDir ?? defaultKeysDir();
1478
+ const dataDir = opts.dataDir ?? defaultDataDir();
1479
+ // Resolve client selection
1480
+ const clientOpt = opts.client;
1481
+ const noMcp = opts.noMcp === true;
1482
+ const selectedClients = [];
1483
+ if (clientOpt === "none" || noMcp) {
1484
+ // Skip MCP entirely — just init + agent
1485
+ }
1486
+ else if (clientOpt === "all") {
1487
+ // Wire all detected clients
1488
+ }
1489
+ else if (clientOpt) {
1490
+ // Wire a specific client
1491
+ const valid = ["claude-code", "codex", "gemini", "cursor"];
1492
+ if (!valid.includes(clientOpt)) {
1493
+ console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
1494
+ process.exit(1);
1495
+ }
1496
+ selectedClients.push(clientOpt);
1497
+ }
1498
+ // If no --client flag: interactive detection later
1499
+ // ── Step 1: Ensure Flair is initialized ──
1500
+ let alreadyInitialized = false;
1501
+ let alreadyRunning = false;
1502
+ let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
1503
+ const adminUser = DEFAULT_ADMIN_USER;
1504
+ // Check if Flair is already initialized (config with port exists)
1505
+ try {
1506
+ const cp = configPath();
1507
+ if (existsSync(cp)) {
1508
+ const yaml = readFileSync(cp, "utf-8");
1509
+ if (yaml.match(/port:\s*\d+/)) {
1510
+ alreadyInitialized = true;
1511
+ console.log("Flair already initialized — skipping init.");
1512
+ }
1513
+ }
1514
+ }
1515
+ catch { /* not initialized */ }
1516
+ if (!alreadyInitialized) {
1517
+ const major = parseInt(process.version.slice(1), 10);
1518
+ if (major < 18)
1519
+ throw new Error(`Node.js >= 18 required (found ${process.version})`);
1520
+ // Only generate a password if none provided via env (fresh install)
1521
+ // If we generate, write it atomically to ~/.flair/admin-pass
1522
+ let adminPassGenerated = false;
1523
+ if (!adminPass) {
1524
+ adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
1525
+ adminPassGenerated = true;
1526
+ // Atomic write: create temp file in same dir, then rename
1527
+ const flairDir = join(homedir(), ".flair");
1528
+ mkdirSync(flairDir, { recursive: true });
1529
+ const adminPassPath = join(flairDir, "admin-pass");
1530
+ const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
1531
+ const finalTempPath = join(tempPath, "admin-pass");
1532
+ try {
1533
+ writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
1534
+ renameSync(finalTempPath, adminPassPath);
1535
+ rmSync(tempPath, { recursive: true, force: true });
1536
+ }
1537
+ catch (err) {
1538
+ // Clean up temp dir on failure
1539
+ try {
1540
+ rmSync(tempPath, { recursive: true, force: true });
1541
+ }
1542
+ catch { }
1543
+ throw err;
1544
+ }
1545
+ console.log(`Admin password saved to: ${adminPassPath}`);
1546
+ }
1547
+ // Check if Harper is already running on this port
1548
+ try {
1549
+ const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
1550
+ if (res.status > 0) {
1551
+ alreadyRunning = true;
1552
+ console.log(`Harper already running on port ${httpPort} — skipping start`);
1553
+ }
1554
+ }
1555
+ catch { /* not running */ }
1556
+ if (!alreadyRunning) {
1557
+ const bin = harperBin();
1558
+ if (!bin)
1559
+ throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
1560
+ mkdirSync(dataDir, { recursive: true });
1561
+ const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
1562
+ const harperSetConfig = JSON.stringify({
1563
+ rootPath: dataDir,
1564
+ http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
1565
+ operationsApi: { network: { port: opsPort, cors: true }, domainSocket: join(dataDir, "operations-server") },
1566
+ mqtt: { network: { port: null }, webSocket: false },
1567
+ localStudio: { enabled: false },
1568
+ authentication: { authorizeLocal: true, enableSessions: true },
1569
+ });
1570
+ const env = {
1571
+ ...process.env,
1572
+ ROOTPATH: dataDir,
1573
+ HARPER_SET_CONFIG: harperSetConfig,
1574
+ DEFAULTS_MODE: "dev",
1575
+ HDB_ADMIN_USERNAME: adminUser,
1576
+ HDB_ADMIN_PASSWORD: adminPass,
1577
+ THREADS_COUNT: "1",
1578
+ NODE_HOSTNAME: "localhost",
1579
+ HTTP_PORT: String(httpPort),
1580
+ OPERATIONSAPI_NETWORK_PORT: String(opsPort),
1581
+ LOCAL_STUDIO: "false",
1582
+ };
1583
+ if (alreadyInstalled) {
1584
+ console.log("Existing Harper installation found — skipping install.");
1585
+ }
1586
+ else {
1587
+ const installEnv = { ...env, HOME: join(dataDir, "..") };
1588
+ console.log("Installing Harper...");
1589
+ console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
1590
+ await new Promise((resolve, reject) => {
1591
+ let output = "";
1592
+ let dotTimer = null;
1593
+ const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
1594
+ dotTimer = setInterval(() => process.stdout.write("."), 3000);
1595
+ install.stdout?.on("data", (d) => { output += d.toString(); });
1596
+ install.stderr?.on("data", (d) => { output += d.toString(); });
1597
+ install.on("exit", (code) => {
1598
+ if (dotTimer) {
1599
+ clearInterval(dotTimer);
1600
+ process.stdout.write("\n");
1601
+ }
1602
+ code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
1603
+ });
1604
+ install.on("error", (err) => {
1605
+ if (dotTimer) {
1606
+ clearInterval(dotTimer);
1607
+ process.stdout.write("\n");
1608
+ }
1609
+ reject(err);
1610
+ });
1611
+ setTimeout(() => {
1612
+ install.kill();
1613
+ if (dotTimer) {
1614
+ clearInterval(dotTimer);
1615
+ process.stdout.write("\n");
1616
+ }
1617
+ reject(new Error(`Harper install timed out: ${output}`));
1618
+ }, 60_000);
1619
+ });
1620
+ }
1621
+ // Start Harper
1622
+ console.log(`Starting Harper on port ${httpPort}...`);
1623
+ const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
1624
+ proc.unref();
1625
+ }
1626
+ // Wait for health
1627
+ console.log("Waiting for Harper health check...");
1628
+ await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
1629
+ console.log("Harper is healthy ✓");
1630
+ // Write config so other commands can find this instance
1631
+ writeConfig(httpPort);
1632
+ }
1633
+ else {
1634
+ // Flair already initialized — resolve admin pass from env or running instance
1635
+ adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
1636
+ }
1637
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
1638
+ // ── Step 2: Detect MCP clients ──
1639
+ let clients = detectClients();
1640
+ if (selectedClients.length > 0) {
1641
+ // Explicit --client flag — override detection
1642
+ if (clientOpt === "all" || clientOpt === "none" || noMcp) {
1643
+ // all/none handled below
1644
+ }
1645
+ else {
1646
+ // Filter to only the selected client
1647
+ clients = [{ id: selectedClients[0], label: selectedClients[0], detected: true }];
1648
+ }
1649
+ }
1650
+ if (!clientOpt && !noMcp) {
1651
+ // No --client flag — print detected clients
1652
+ const detected = clients.filter(c => c.detected);
1653
+ if (detected.length === 0) {
1654
+ console.log("No MCP clients detected. Run with --client <name> to wire a specific client.");
1655
+ }
1656
+ else {
1657
+ console.log(`Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
1658
+ }
1659
+ }
1660
+ // ── Step 3: Resolve agent identity ──
1661
+ const agentId = opts.agent ?? (() => {
1662
+ try {
1663
+ const hn = hostname();
1664
+ return hn.split(".")[0];
1665
+ }
1666
+ catch {
1667
+ return "flair-agent";
1668
+ }
1669
+ })();
1670
+ // Validate agentId to prevent path traversal
1671
+ const VALID_AGENT_ID = /^[a-zA-Z0-9_-]+$/;
1672
+ if (!VALID_AGENT_ID.test(agentId)) {
1673
+ throw new Error(`Invalid agent ID: ${agentId}. Agent ID must contain only letters, numbers, underscores, and hyphens.`);
1674
+ }
1675
+ let agentExists = false;
1676
+ // Check if agent already exists locally
1677
+ const privPath = privKeyPath(agentId, keysDir);
1678
+ if (existsSync(privPath)) {
1679
+ agentExists = true;
1680
+ console.log(`Agent '${agentId}' already exists ✓`);
732
1681
  }
733
1682
  else {
1683
+ // Create agent
1684
+ mkdirSync(keysDir, { recursive: true });
1685
+ const pubPath = pubKeyPath(agentId, keysDir);
734
1686
  console.log("Generating Ed25519 keypair...");
735
1687
  const kp = nacl.sign.keyPair();
736
- // Store only the 32-byte seed (first 32 bytes of secretKey)
737
1688
  const seed = kp.secretKey.slice(0, 32);
738
1689
  writeFileSync(privPath, Buffer.from(seed));
739
1690
  chmodSync(privPath, 0o600);
740
1691
  writeFileSync(pubPath, Buffer.from(kp.publicKey));
741
- pubKeyB64url = b64url(kp.publicKey);
1692
+ const pubKeyB64url = b64url(kp.publicKey);
742
1693
  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}`);
790
- }
791
- }
792
- console.log(`\n ${soulEntries.length} soul entries saved.`);
793
- console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
1694
+ // Seed agent - use REST API for local Harper, Ops API only when --ops-target is specified
1695
+ if (opts.opsTarget) {
1696
+ // Remote Harper via explicit ops target
1697
+ console.log(`Seeding agent '${agentId}' via operations API (--ops-target)...`);
1698
+ await seedAgentViaOpsApi(opts.opsTarget, agentId, pubKeyB64url, adminUser, adminPass);
794
1699
  }
795
1700
  else {
796
- console.log(`\n No soul entries saved. Add later with:`);
797
- console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
798
- console.log(` Or run \`flair doctor\` anytime for a nudge.`);
1701
+ // Local Harper - use REST API
1702
+ console.log(`Seeding agent '${agentId}' via REST API...`);
1703
+ await seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass);
799
1704
  }
1705
+ console.log(`Agent '${agentId}' registered ✓`);
800
1706
  }
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");
1707
+ // ── Step 4: Wire MCP clients ──
810
1708
  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 ✓`);
1709
+ const wiringResults = [];
1710
+ if (!noMcp && clientOpt !== "none") {
1711
+ const toWire = clientOpt === "all"
1712
+ ? clients.filter(c => c.detected).map(c => c.id)
1713
+ : selectedClients.length > 0
1714
+ ? selectedClients
1715
+ : clients.filter(c => c.detected).map(c => c.id);
1716
+ for (const clientId of toWire) {
1717
+ let result;
1718
+ switch (clientId) {
1719
+ case "claude-code":
1720
+ result = wireClaudeCode(mcpEnv, httpUrl);
1721
+ break;
1722
+ case "codex":
1723
+ result = wireCodex(mcpEnv);
1724
+ break;
1725
+ case "gemini":
1726
+ result = wireGemini(mcpEnv);
1727
+ break;
1728
+ case "cursor":
1729
+ result = wireCursor(mcpEnv);
1730
+ break;
1731
+ default: result = { ok: false, message: `Unknown client: ${clientId}` };
823
1732
  }
824
- 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.`);
1733
+ wiringResults.push({ client: clientId, message: result.message });
1734
+ console.log(` ${result.ok ? "✓" : "✗"} ${result.message}`);
1735
+ }
1736
+ }
1737
+ // ── Step 5: Smoke test the MCP server ──
1738
+ if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
1739
+ console.log("Smoke-testing MCP server...");
1740
+ try {
1741
+ // Launch flair-mcp and send initialize request over stdio
1742
+ const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
1743
+ env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
1744
+ stdio: ["pipe", "pipe", "pipe"],
1745
+ timeout: 15_000,
1746
+ });
1747
+ // Send initialize request
1748
+ const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-install", version: "1.0.0" } } });
1749
+ mcpProc.stdin.write(initMsg + "\n");
1750
+ mcpProc.stdin.end();
1751
+ let stdout = "";
1752
+ mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
1753
+ await new Promise((resolve, reject) => {
1754
+ mcpProc.on("exit", (code) => {
1755
+ if (code === 0 && stdout.length > 0) {
1756
+ resolve();
1757
+ }
1758
+ else {
1759
+ reject(new Error(`MCP server exited with code ${code}`));
1760
+ }
1761
+ });
1762
+ mcpProc.on("error", reject);
1763
+ setTimeout(() => { mcpProc.kill(); reject(new Error("MCP smoke test timed out")); }, 15_000);
1764
+ });
1765
+ // Check for a valid JSON-RPC response
1766
+ try {
1767
+ const lines = stdout.split("\n").filter(l => l.trim());
1768
+ for (const line of lines) {
1769
+ const parsed = JSON.parse(line);
1770
+ if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
1771
+ console.log(" ✓ MCP server responded");
1772
+ break;
1773
+ }
1774
+ }
1775
+ }
1776
+ catch {
1777
+ console.log(" ⚠ MCP server responded but response could not be parsed");
830
1778
  }
831
1779
  }
832
- else {
833
- console.log(`\n MCP config (add to ~/.claude.json):`);
834
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1780
+ catch (err) {
1781
+ const message = err instanceof Error ? err.message : String(err);
1782
+ console.log(` MCP smoke test failed: ${message}`);
1783
+ console.log(" Use --skip-smoke to bypass.");
835
1784
  }
836
1785
  }
837
- catch {
838
- console.log(`\n MCP config (add manually to ~/.claude.json):`);
839
- console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1786
+ // ── Step 6: Summary ──
1787
+ console.log("");
1788
+ console.log(" Flair installed.");
1789
+ console.log(` Agent: ${agentId}`);
1790
+ if (existsSync(privKeyPath(agentId, keysDir))) {
1791
+ console.log(` Private key: ${privKeyPath(agentId, keysDir)}`);
840
1792
  }
1793
+ console.log(` Local: ${httpUrl}`);
1794
+ console.log(` MCP: ${wiringResults.length > 0 ? wiringResults.map(r => r.client).join(", ") + " ✓ wired" : "none wired"}`);
1795
+ console.log("");
1796
+ console.log(` Try it: in Claude Code, ask the agent "what do you remember about me?"`);
841
1797
  });
842
1798
  // ─── flair agent ─────────────────────────────────────────────────────────────
843
1799
  const agent = program.command("agent").description("Manage Flair agents");
@@ -891,7 +1847,13 @@ agent
891
1847
  .option("--port <port>", "Harper HTTP port")
892
1848
  .action(async (opts) => {
893
1849
  const port = resolveHttpPort(opts);
894
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1850
+ // fromEnv is true ONLY when the resolved value came from env (no inline override).
1851
+ const adminPassFromEnv = !opts.adminPass && (!!process.env.FLAIR_ADMIN_PASS || !!process.env.HDB_ADMIN_PASSWORD);
1852
+ if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
1853
+ console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
1854
+ "to keep secrets out of shell history.");
1855
+ }
1856
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
895
1857
  if (adminPass) {
896
1858
  // Use admin basic auth against ops API to list agents directly
897
1859
  const opsPort = resolveOpsPort(opts);
@@ -899,18 +1861,37 @@ agent
899
1861
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
900
1862
  method: "POST",
901
1863
  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" }),
1864
+ 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
1865
  });
904
1866
  if (!res.ok) {
905
1867
  const text = await res.text().catch(() => "");
906
1868
  console.error(`Error: ${res.status} ${text}`);
907
1869
  process.exit(1);
908
1870
  }
909
- console.log(JSON.stringify(await res.json(), null, 2));
1871
+ const agents = await res.json();
1872
+ agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
1873
+ console.log(JSON.stringify(agents, null, 2));
910
1874
  }
911
1875
  else {
912
- // Try agent-authed API (requires FLAIR_AGENT_ID to be set)
913
- console.log(JSON.stringify(await api("GET", "/Agent"), null, 2));
1876
+ // Localhost operator path: allow IDs-only enumeration without per-agent auth
1877
+ // This treats localhost as a trusted boundary for read-only public metadata
1878
+ const baseUrl = `http://127.0.0.1:${port}`;
1879
+ const res = await fetch(`${baseUrl}/Agent`, {
1880
+ headers: { "Content-Type": "application/json" },
1881
+ });
1882
+ if (!res.ok) {
1883
+ const text = await res.text().catch(() => "");
1884
+ console.error(`Error: ${res.status} ${text}`);
1885
+ process.exit(1);
1886
+ }
1887
+ const data = await res.json();
1888
+ // Filter to IDs-only to respect the localhost trust boundary
1889
+ if (Array.isArray(data)) {
1890
+ console.log(JSON.stringify(data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })), null, 2));
1891
+ }
1892
+ else {
1893
+ console.log(JSON.stringify(data, null, 2));
1894
+ }
914
1895
  }
915
1896
  });
916
1897
  agent
@@ -927,6 +1908,12 @@ agent
927
1908
  .action(async (id, opts) => {
928
1909
  const httpPort = resolveHttpPort(opts);
929
1910
  const opsPort = resolveOpsPort(opts);
1911
+ // fromEnv is true ONLY when the resolved value came from env (no inline override).
1912
+ const adminPassFromEnv = !opts.adminPass && !!process.env.FLAIR_ADMIN_PASS;
1913
+ if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
1914
+ console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
1915
+ "to keep secrets out of shell history.");
1916
+ }
930
1917
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
931
1918
  const adminUser = DEFAULT_ADMIN_USER;
932
1919
  const keysDir = opts.keysDir ?? defaultKeysDir();
@@ -1204,14 +2191,20 @@ principal
1204
2191
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1205
2192
  process.exit(1);
1206
2193
  }
1207
- const kindFilter = opts.kind ? ` WHERE kind = '${opts.kind}'` : "";
1208
2194
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
2195
+ const conditions = opts.kind
2196
+ ? [{ search_attribute: "kind", search_type: "equals", search_value: opts.kind }]
2197
+ : [{ search_attribute: "id", search_type: "starts_with", search_value: "" }];
1209
2198
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1210
2199
  method: "POST",
1211
2200
  headers: { "Content-Type": "application/json", Authorization: auth },
1212
2201
  body: JSON.stringify({
1213
- operation: "sql",
1214
- sql: `SELECT id, name, kind, status, defaultTrustTier, admin, runtime, createdAt FROM flair.Agent${kindFilter} ORDER BY createdAt`,
2202
+ operation: "search_by_conditions",
2203
+ schema: "flair",
2204
+ table: "Agent",
2205
+ operator: "and",
2206
+ conditions,
2207
+ get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "admin", "runtime", "createdAt"],
1215
2208
  }),
1216
2209
  });
1217
2210
  if (!res.ok) {
@@ -1220,6 +2213,7 @@ principal
1220
2213
  process.exit(1);
1221
2214
  }
1222
2215
  const records = await res.json();
2216
+ records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
1223
2217
  if (records.length === 0) {
1224
2218
  console.log("No principals found.");
1225
2219
  return;
@@ -1382,8 +2376,13 @@ idp
1382
2376
  method: "POST",
1383
2377
  headers: { "Content-Type": "application/json", Authorization: auth },
1384
2378
  body: JSON.stringify({
1385
- operation: "sql",
1386
- sql: "SELECT id, name, issuer, requiredDomain, jitProvision, enabled, createdAt FROM flair.IdpConfig ORDER BY createdAt",
2379
+ operation: "search_by_value",
2380
+ schema: "flair",
2381
+ table: "IdpConfig",
2382
+ search_attribute: "id",
2383
+ search_type: "starts_with",
2384
+ search_value: "",
2385
+ get_attributes: ["id", "name", "issuer", "requiredDomain", "jitProvision", "enabled", "createdAt"],
1387
2386
  }),
1388
2387
  });
1389
2388
  if (!res.ok) {
@@ -1392,6 +2391,7 @@ idp
1392
2391
  process.exit(1);
1393
2392
  }
1394
2393
  const records = await res.json();
2394
+ records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
1395
2395
  if (records.length === 0) {
1396
2396
  console.log("No IdPs configured.");
1397
2397
  return;
@@ -1443,18 +2443,14 @@ idp
1443
2443
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1444
2444
  process.exit(1);
1445
2445
  }
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) {
2446
+ let cfg;
2447
+ try {
2448
+ cfg = await api("GET", `/IdpConfig/${id}`);
2449
+ }
2450
+ catch {
1454
2451
  console.error(`IdP '${id}' not found`);
1455
2452
  process.exit(1);
1456
2453
  }
1457
- const cfg = records[0];
1458
2454
  console.log(`Testing IdP: ${cfg.name} (${cfg.issuer})`);
1459
2455
  console.log(` JWKS endpoint: ${cfg.jwksUri}`);
1460
2456
  try {
@@ -1468,7 +2464,8 @@ idp
1468
2464
  console.log(` ✅ JWKS reachable — ${keyCount} key(s) found`);
1469
2465
  }
1470
2466
  catch (err) {
1471
- console.error(` ❌ JWKS fetch error: ${err.message}`);
2467
+ const message = err instanceof Error ? err.message : String(err);
2468
+ console.error(` ❌ JWKS fetch error: ${message}`);
1472
2469
  process.exit(1);
1473
2470
  }
1474
2471
  });
@@ -1581,7 +2578,7 @@ async function loadInstanceSecretKey(instanceId, opts) {
1581
2578
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1582
2579
  method: "POST",
1583
2580
  headers: { "Content-Type": "application/json", Authorization: auth },
1584
- body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.Instance WHERE id = '${instanceId}'` }),
2581
+ body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Instance", search_attribute: "id", search_type: "equals", search_value: instanceId, get_attributes: ["*"] }),
1585
2582
  });
1586
2583
  if (res.ok) {
1587
2584
  const rows = await res.json();
@@ -1607,14 +2604,18 @@ federation
1607
2604
  .command("status")
1608
2605
  .description("Show federation status and peer connections")
1609
2606
  .option("--port <port>", "Harper HTTP port")
2607
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2608
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
1610
2609
  .action(async (opts) => {
2610
+ const target = resolveTarget(opts);
2611
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
1611
2612
  try {
1612
- const instance = await api("GET", "/FederationInstance");
2613
+ const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
1613
2614
  console.log(`Instance: ${instance.id} (${instance.role})`);
1614
2615
  console.log(`Public key: ${instance.publicKey}`);
1615
2616
  console.log(`Status: ${instance.status}`);
1616
2617
  console.log();
1617
- const { peers } = await api("GET", "/FederationPeers");
2618
+ const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
1618
2619
  if (peers.length === 0) {
1619
2620
  console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
1620
2621
  }
@@ -1638,22 +2639,38 @@ federation
1638
2639
  .option("--port <port>", "Harper HTTP port")
1639
2640
  .option("--admin-pass <pass>", "Admin password")
1640
2641
  .option("--ops-port <port>", "Harper operations API port")
1641
- .option("--token <token>", "One-time pairing token from hub admin")
2642
+ .option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN)")
2643
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2644
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
1642
2645
  .action(async (hubUrl, opts) => {
2646
+ const target = resolveTarget(opts);
2647
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
1643
2648
  try {
1644
- const instance = await api("GET", "/FederationInstance");
1645
- console.log(`Local instance: ${instance.id} (${instance.role})`);
2649
+ const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
2650
+ console.log(`${target ? "Remote" : "Local"} instance: ${instance.id} (${instance.role})`);
1646
2651
  if (!opts.token) {
1647
2652
  console.error("Error: --token is required. Ask the hub admin to run 'flair federation token' and provide the token.");
1648
2653
  process.exit(1);
1649
2654
  }
1650
- // Load secret key and sign the pairing request
2655
+ // Warning: inline token may leak to shell history.
2656
+ // fromEnv is true ONLY when the resolved value came from env (no inline override).
2657
+ const tokenFromEnv = !opts.token && !!process.env.FLAIR_PAIRING_TOKEN;
2658
+ if (shouldShowInlineSecretWarning(opts.token, tokenFromEnv, new Set(["--token"]), "--token")) {
2659
+ console.error("warning: --token passed inline. Consider --token-from <file> or FLAIR_PAIRING_TOKEN env " +
2660
+ "to keep secrets out of shell history.");
2661
+ }
2662
+ // Load secret key and sign the pairing request. The pairing token is
2663
+ // included in the signed body (not in an Authorization header) because
2664
+ // Harper's auth layer claims any "Bearer X" Authorization header for
2665
+ // itself and 401s before our resource ever runs.
1651
2666
  const secretKey = await loadInstanceSecretKey(instance.id, opts);
2667
+ // Env var fallback for --token: FLAIR_PAIRING_TOKEN
2668
+ const pairingToken = opts.token || process.env.FLAIR_PAIRING_TOKEN;
1652
2669
  const pairBody = {
1653
2670
  instanceId: instance.id,
1654
2671
  publicKey: instance.publicKey,
1655
2672
  role: "spoke",
1656
- pairingToken: opts.token,
2673
+ pairingToken,
1657
2674
  };
1658
2675
  const signedBody = signRequestBody(pairBody, secretKey);
1659
2676
  const res = await fetch(`${hubUrl}/FederationPair`, {
@@ -1668,12 +2685,12 @@ federation
1668
2685
  }
1669
2686
  const result = await res.json();
1670
2687
  console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
1671
- // Record the hub as our peer locally
1672
- const opsPort = resolveOpsPort(opts);
2688
+ // Record the hub as our peer locally or remotely depending on --target
1673
2689
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1674
2690
  if (adminPass) {
1675
2691
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1676
- await fetch(`http://127.0.0.1:${opsPort}/`, {
2692
+ const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
2693
+ await fetch(`${opsEndpoint}/`, {
1677
2694
  method: "POST",
1678
2695
  headers: { "Content-Type": "application/json", Authorization: auth },
1679
2696
  body: JSON.stringify({
@@ -1687,6 +2704,7 @@ federation
1687
2704
  updatedAt: new Date().toISOString(),
1688
2705
  }],
1689
2706
  }),
2707
+ signal: AbortSignal.timeout(10_000),
1690
2708
  });
1691
2709
  }
1692
2710
  }
@@ -1702,16 +2720,20 @@ federation
1702
2720
  .option("--admin-pass <pass>", "Admin password")
1703
2721
  .option("--ops-port <port>", "Harper operations API port")
1704
2722
  .option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
2723
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2724
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
1705
2725
  .action(async (opts) => {
2726
+ const target = resolveTarget(opts);
2727
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
1706
2728
  try {
1707
2729
  const { randomBytes } = await import("node:crypto");
1708
2730
  const token = randomBytes(24).toString("base64url");
1709
2731
  const ttlMinutes = parseInt(opts.ttl, 10) || 60;
1710
2732
  const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
1711
- const opsPort = resolveOpsPort(opts);
2733
+ const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
1712
2734
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1713
2735
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1714
- await fetch(`http://127.0.0.1:${opsPort}/`, {
2736
+ const opsRes = await fetch(`${opsEndpoint}/`, {
1715
2737
  method: "POST",
1716
2738
  headers: { "Content-Type": "application/json", Authorization: auth },
1717
2739
  body: JSON.stringify({
@@ -1722,7 +2744,12 @@ federation
1722
2744
  expiresAt,
1723
2745
  }],
1724
2746
  }),
2747
+ signal: AbortSignal.timeout(10_000),
1725
2748
  });
2749
+ if (!opsRes.ok) {
2750
+ const detail = await opsRes.text().catch(() => "");
2751
+ throw new Error(`Failed to persist pairing token (${opsRes.status}): ${detail || "no body"}`);
2752
+ }
1726
2753
  console.log(`Pairing token (expires in ${ttlMinutes}m):`);
1727
2754
  console.log(` ${token}`);
1728
2755
  console.log(`\nGive this to the spoke admin to run:`);
@@ -1733,46 +2760,48 @@ federation
1733
2760
  process.exit(1);
1734
2761
  }
1735
2762
  });
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) => {
2763
+ export async function runFederationSyncOnce(opts) {
2764
+ const target = resolveTarget(opts);
2765
+ const baseUrl = target ? target.replace(/\/$/, "") : undefined;
2766
+ const apiOpts = baseUrl ? { baseUrl } : undefined;
1743
2767
  try {
1744
- const { peers } = await api("GET", "/FederationPeers");
2768
+ const { peers } = await api("GET", "/FederationPeers", undefined, apiOpts);
1745
2769
  const hub = peers.find((p) => p.role === "hub" && p.status !== "revoked");
1746
2770
  if (!hub) {
1747
- console.error("No hub peer configured. Use 'flair federation pair' first.");
1748
- process.exit(1);
2771
+ return { pushed: 0, skipped: 0, error: new Error("No hub peer configured. Use 'flair federation pair' first.") };
1749
2772
  }
1750
2773
  console.log(`Syncing to hub: ${hub.id}...`);
1751
2774
  const since = hub.lastSyncAt ?? new Date(0).toISOString();
1752
- const opsPort = resolveOpsPort(opts);
2775
+ const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
1753
2776
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1754
2777
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1755
2778
  const tables = ["Memory", "Soul", "Agent", "Relationship"];
1756
2779
  const records = [];
1757
- const instance = await api("GET", "/FederationInstance");
2780
+ const instance = await api("GET", "/FederationInstance", undefined, apiOpts);
1758
2781
  for (const table of tables) {
2782
+ let res;
1759
2783
  try {
1760
- const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
2784
+ res = await fetch(`${opsEndpoint}/`, {
1761
2785
  method: "POST",
1762
2786
  headers: { "Content-Type": "application/json", Authorization: auth },
1763
- body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.${table} WHERE updatedAt > '${since}'` }),
2787
+ 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: ["*"] }),
2788
+ signal: AbortSignal.timeout(15_000),
1764
2789
  });
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
- }
1769
- }
1770
2790
  }
1771
- catch { }
2791
+ catch (err) {
2792
+ return { pushed: 0, skipped: 0, error: err instanceof Error ? err : new Error(String(err)) };
2793
+ }
2794
+ if (!res.ok) {
2795
+ const text = await res.text().catch(() => "");
2796
+ return { pushed: 0, skipped: 0, error: new Error(`SQL query failed (${res.status}): ${text}`) };
2797
+ }
2798
+ for (const row of await res.json()) {
2799
+ records.push({ table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id });
2800
+ }
1772
2801
  }
1773
2802
  if (records.length === 0) {
1774
2803
  console.log("No changes since last sync.");
1775
- return;
2804
+ return { pushed: 0, skipped: 0 };
1776
2805
  }
1777
2806
  // Sign the sync request with our instance key
1778
2807
  const secretKey = await loadInstanceSecretKey(instance.id, opts);
@@ -1784,17 +2813,78 @@ federation
1784
2813
  body: JSON.stringify(signedSyncBody),
1785
2814
  });
1786
2815
  if (!syncRes.ok) {
1787
- console.error(`Sync failed: ${syncRes.status} ${await syncRes.text().catch(() => "")}`);
1788
- process.exit(1);
2816
+ const text = await syncRes.text().catch(() => "");
2817
+ return { pushed: 0, skipped: 0, error: new Error(`Sync failed: ${syncRes.status} ${text}`) };
1789
2818
  }
1790
2819
  const result = await syncRes.json();
1791
2820
  console.log(`✅ Synced ${result.merged} records (${result.skipped} skipped) in ${result.durationMs}ms`);
2821
+ return { pushed: result.merged ?? 0, skipped: result.skipped ?? 0 };
1792
2822
  }
1793
2823
  catch (err) {
1794
- console.error(`Error: ${err.message}`);
2824
+ return { pushed: 0, skipped: 0, error: err instanceof Error ? err : new Error(String(err)) };
2825
+ }
2826
+ }
2827
+ federation
2828
+ .command("sync")
2829
+ .description("Push local changes to the hub")
2830
+ .option("--port <port>", "Harper HTTP port")
2831
+ .option("--admin-pass <pass>", "Admin password")
2832
+ .option("--ops-port <port>", "Harper operations API port")
2833
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2834
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
2835
+ .action(async (opts) => {
2836
+ const r = await runFederationSyncOnce(opts);
2837
+ if (r.error) {
2838
+ console.error(`Error: ${r.error.message}`);
1795
2839
  process.exit(1);
1796
2840
  }
1797
2841
  });
2842
+ export async function runFederationWatch(opts) {
2843
+ const intervalMs = Math.max(5, parseFloat(opts.interval) || 30) * 1000;
2844
+ let stopped = false;
2845
+ const stop = () => { stopped = true; };
2846
+ process.on("SIGINT", stop);
2847
+ process.on("SIGTERM", stop);
2848
+ console.log(`flair federation watch — interval ${intervalMs / 1000}s. Ctrl-C to stop.`);
2849
+ try {
2850
+ while (!stopped) {
2851
+ try {
2852
+ const r = await runFederationSyncOnce(opts);
2853
+ const ts = new Date().toISOString();
2854
+ if (r.error)
2855
+ console.error(`[${ts}] sync error: ${r.error.message}`);
2856
+ else
2857
+ console.log(`[${ts}] sync ok — pushed ${r.pushed}, skipped ${r.skipped}`);
2858
+ }
2859
+ catch (err) {
2860
+ console.error(`[${new Date().toISOString()}] watch loop error: ${err.message}`);
2861
+ }
2862
+ // Sleep but exit early on signal
2863
+ const t = Date.now();
2864
+ while (!stopped && Date.now() - t < intervalMs) {
2865
+ const remaining = intervalMs - (Date.now() - t);
2866
+ await new Promise((r) => setTimeout(r, Math.min(250, remaining)));
2867
+ }
2868
+ }
2869
+ }
2870
+ finally {
2871
+ process.removeListener("SIGINT", stop);
2872
+ process.removeListener("SIGTERM", stop);
2873
+ }
2874
+ console.log("flair federation watch — stopped.");
2875
+ }
2876
+ federation
2877
+ .command("watch")
2878
+ .description("Run federation sync in a loop (foreground daemon)")
2879
+ .option("--interval <seconds>", "Seconds between syncs", "30")
2880
+ .option("--port <port>", "Harper HTTP port")
2881
+ .option("--admin-pass <pass>", "Admin password")
2882
+ .option("--ops-port <port>", "Harper operations API port")
2883
+ .option("--target <url>", "Remote Flair URL")
2884
+ .option("--ops-target <url>", "Explicit ops API URL")
2885
+ .action(async (opts) => {
2886
+ await runFederationWatch(opts);
2887
+ });
1798
2888
  // ─── flair rem ───────────────────────────────────────────────────────────────
1799
2889
  // Memory hygiene and reflection: light (NREM), rapid (REM), restorative (deep).
1800
2890
  const rem = program.command("rem").description("Memory hygiene and reflection");
@@ -2057,7 +3147,8 @@ function oauthDetailLines(o) {
2057
3147
  }
2058
3148
  async function fetchHealthDetail(opts) {
2059
3149
  const port = resolveHttpPort(opts);
2060
- const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
3150
+ // --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
3151
+ const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
2061
3152
  let healthy = false;
2062
3153
  let healthData = null;
2063
3154
  try {
@@ -2114,6 +3205,7 @@ const statusCmd = program
2114
3205
  .description("Show Flair instance status, memory stats, and agent info")
2115
3206
  .option("--port <port>", "Harper HTTP port")
2116
3207
  .option("--url <url>", "Flair base URL (overrides --port)")
3208
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
2117
3209
  .option("--json", "Output as JSON")
2118
3210
  .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
2119
3211
  .action(async (opts) => {
@@ -2142,17 +3234,42 @@ const statusCmd = program
2142
3234
  const agents = healthData?.agents;
2143
3235
  const memories = healthData?.memories;
2144
3236
  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.
3237
+ // Scope warnings to the filtered agent if --agent is set
3238
+ const scopedWarnings = opts.agent && healthData?.agents?.perAgent
3239
+ ? warnings.filter((w) => {
3240
+ // Hash-fallback warnings contain agent-specific counts
3241
+ if (w.message.includes("hash-fallback")) {
3242
+ const match = w.message.match(/\b(\d+)\/(\d+) \((\d+)%\)/);
3243
+ if (match) {
3244
+ const hashCount = parseInt(match[1]);
3245
+ const totalCount = parseInt(match[2]);
3246
+ const agentRow = healthData.agents.perAgent.find((r) => r.id === opts.agent);
3247
+ if (agentRow && agentRow.hashFallback === hashCount && agentRow.memoryCount === totalCount) {
3248
+ return true;
3249
+ }
3250
+ return false;
3251
+ }
3252
+ }
3253
+ // Mixed-model warnings are fleet-wide; keep them
3254
+ if (w.message.includes("multiple embedding models"))
3255
+ return true;
3256
+ // Federation warnings are fleet-wide; keep them
3257
+ if (w.message.includes("federation"))
3258
+ return true;
3259
+ // REM warnings are fleet-wide; keep them
3260
+ if (w.message.includes("REM") || w.message.includes("nightly"))
3261
+ return true;
3262
+ // Default: keep fleet-wide warnings
3263
+ return !w.message.includes(opts.agent);
3264
+ })
3265
+ : warnings;
3266
+ const hasWarn = scopedWarnings.some((w) => w.level === "warn");
2150
3267
  const headerIcon = hasWarn ? "🟡" : "🟢";
2151
3268
  console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
2152
3269
  console.log(` URL: ${baseUrl}`);
2153
- if (warnings.length > 0) {
2154
- console.log(`\n⚠ Warnings: ${warnings.length}`);
2155
- for (const w of warnings)
3270
+ if (scopedWarnings.length > 0) {
3271
+ console.log(`\n⚠ Warnings: ${scopedWarnings.length}`);
3272
+ for (const w of scopedWarnings)
2156
3273
  console.log(` • ${w.level} ${w.message}`);
2157
3274
  }
2158
3275
  if (memories) {
@@ -2248,6 +3365,9 @@ const statusCmd = program
2248
3365
  if (f.pendingTokens > 0)
2249
3366
  console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
2250
3367
  }
3368
+ else {
3369
+ console.log("\nFederation: not configured");
3370
+ }
2251
3371
  if (healthData?.oauth) {
2252
3372
  const lines = oauthSummaryLines(healthData.oauth);
2253
3373
  for (const line of lines)
@@ -2263,6 +3383,9 @@ const statusCmd = program
2263
3383
  if (b.lastExport)
2264
3384
  console.log(` Last export: ${relativeTime(b.lastExport)}`);
2265
3385
  }
3386
+ else {
3387
+ console.log("\nBridges: none installed");
3388
+ }
2266
3389
  if (healthData?.disk) {
2267
3390
  const d = healthData.disk;
2268
3391
  console.log("\nDisk:");
@@ -2270,8 +3393,8 @@ const statusCmd = program
2270
3393
  console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
2271
3394
  }
2272
3395
  console.log("");
2273
- if (warnings.length > 0)
2274
- console.log(` Health: ⚠ ${warnings.length} warning(s)`);
3396
+ if (scopedWarnings.length > 0)
3397
+ console.log(` Health: ⚠ ${scopedWarnings.length} warning(s)`);
2275
3398
  else
2276
3399
  console.log(` Health: ✅ all checks passing`);
2277
3400
  });
@@ -2806,7 +3929,7 @@ program
2806
3929
  program
2807
3930
  .command("reembed")
2808
3931
  .description("Re-generate embeddings for memories with stale or missing model tags")
2809
- .requiredOption("--agent <id>", "Agent ID to re-embed memories for")
3932
+ .option("--agent <id>", "Agent ID to re-embed memories for (defaults to all agents with stale rows)")
2810
3933
  .option("--stale-only", "Only re-embed memories with mismatched model tag")
2811
3934
  .option("--dry-run", "Show count without modifying")
2812
3935
  .option("--port <port>", "Harper HTTP port")
@@ -2821,13 +3944,100 @@ program
2821
3944
  const batchSize = Number(opts.batchSize);
2822
3945
  const delayMs = Number(opts.delayMs);
2823
3946
  const currentModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
2824
- console.log(`Re-embedding memories for agent: ${agentId}`);
3947
+ if (agentId) {
3948
+ console.log(`Re-embedding memories for agent: ${agentId}`);
3949
+ }
3950
+ else {
3951
+ console.log("Re-embedding memories for all agents with stale rows");
3952
+ }
2825
3953
  console.log(`Current model: ${currentModel}`);
2826
3954
  if (staleOnly)
2827
3955
  console.log("Mode: stale-only (skipping up-to-date memories)");
2828
3956
  if (dryRun)
2829
3957
  console.log("Mode: dry-run (no modifications)");
2830
3958
  console.log("");
3959
+ // When no agent specified, use admin auth to fetch all memories
3960
+ if (!agentId) {
3961
+ const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
3962
+ if (!adminPass) {
3963
+ console.error("❌ Admin password required when --agent is not specified (set FLAIR_ADMIN_PASS or HDB_ADMIN_PASSWORD)");
3964
+ process.exit(1);
3965
+ }
3966
+ // Fetch all memories with admin auth
3967
+ const searchRes = await fetch(`${baseUrl}/SemanticSearch`, {
3968
+ method: "POST",
3969
+ headers: {
3970
+ "Content-Type": "application/json",
3971
+ Authorization: `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`,
3972
+ },
3973
+ body: JSON.stringify({ limit: 10000 }),
3974
+ });
3975
+ if (!searchRes.ok) {
3976
+ console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
3977
+ process.exit(1);
3978
+ }
3979
+ const data = await searchRes.json();
3980
+ const allMemories = data.results ?? [];
3981
+ // Group by agentId
3982
+ const byAgent = new Map();
3983
+ for (const m of allMemories) {
3984
+ if (!m.content)
3985
+ continue;
3986
+ if (staleOnly && m.embeddingModel === currentModel)
3987
+ continue;
3988
+ const agent = m.agentId || "unknown";
3989
+ if (!byAgent.has(agent))
3990
+ byAgent.set(agent, []);
3991
+ byAgent.get(agent).push(m);
3992
+ }
3993
+ // Process each agent
3994
+ let totalProcessed = 0;
3995
+ let totalErrors = 0;
3996
+ const agentCount = byAgent.size;
3997
+ let agentIndex = 0;
3998
+ for (const [agent, memories] of byAgent) {
3999
+ agentIndex++;
4000
+ console.log(`\nAgent ${agentIndex}/${agentCount}: ${agent}`);
4001
+ console.log(` Memories to re-embed: ${memories.length}`);
4002
+ const keysDir = defaultKeysDir();
4003
+ const privPath = privKeyPath(agent, keysDir);
4004
+ if (!existsSync(privPath)) {
4005
+ console.error(` ❌ Key not found: ${privPath} — skipping`);
4006
+ continue;
4007
+ }
4008
+ if (dryRun)
4009
+ continue;
4010
+ let processed = 0;
4011
+ let errors = 0;
4012
+ for (let i = 0; i < memories.length; i += batchSize) {
4013
+ const batch = memories.slice(i, i + batchSize);
4014
+ for (const memory of batch) {
4015
+ try {
4016
+ const updateRes = await authFetch(baseUrl, agent, privPath, "PUT", `/Memory/${memory.id}`, {
4017
+ id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || agent,
4018
+ });
4019
+ if (updateRes.ok)
4020
+ processed++;
4021
+ else
4022
+ errors++;
4023
+ }
4024
+ catch {
4025
+ errors++;
4026
+ }
4027
+ }
4028
+ const pct = Math.round(((i + batch.length) / memories.length) * 100);
4029
+ process.stdout.write(` \r Re-embedded ${processed}/${memories.length} (${pct}%)${errors > 0 ? ` [${errors} errors]` : ""}`);
4030
+ if (i + batchSize < memories.length)
4031
+ await new Promise(r => setTimeout(r, delayMs));
4032
+ }
4033
+ console.log(`\n ✅ Agent ${agent}: ${processed} updated, ${errors} errors`);
4034
+ totalProcessed += processed;
4035
+ totalErrors += errors;
4036
+ }
4037
+ console.log(`\n\n✅ Re-embedding complete: ${totalProcessed} updated, ${totalErrors} errors`);
4038
+ return;
4039
+ }
4040
+ // Original single-agent path
2831
4041
  const keysDir = defaultKeysDir();
2832
4042
  const privPath = privKeyPath(agentId, keysDir);
2833
4043
  if (!existsSync(privPath)) {
@@ -2869,7 +4079,7 @@ program
2869
4079
  for (const memory of batch) {
2870
4080
  try {
2871
4081
  const updateRes = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${memory.id}`, {
2872
- id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined,
4082
+ id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || opts.agent,
2873
4083
  });
2874
4084
  if (updateRes.ok)
2875
4085
  processed++;
@@ -2919,7 +4129,8 @@ program
2919
4129
  }
2920
4130
  }
2921
4131
  catch (e) {
2922
- console.log(` ${red("FAIL")} ${name}: ${e.message?.slice(0, 120)}`);
4132
+ const message = e instanceof Error ? e.message : String(e);
4133
+ console.log(` ${red("FAIL")} ${name}: ${message?.slice(0, 120)}`);
2923
4134
  failed++;
2924
4135
  }
2925
4136
  };
@@ -2963,6 +4174,13 @@ program
2963
4174
  process.exit(1);
2964
4175
  });
2965
4176
  // ─── flair deploy ─────────────────────────────────────────────────────────────
4177
+ // NOTE on env-var naming for `flair deploy`: the FABRIC_* env vars below intentionally
4178
+ // do NOT carry the FLAIR_ prefix that the rest of the CLI uses (FLAIR_ADMIN_PASS,
4179
+ // FLAIR_TARGET, FLAIR_PAIRING_TOKEN, etc.). FABRIC_* credentials are shared with
4180
+ // the broader TPS tooling stack — multiple tools deploy to the same Harper Fabric
4181
+ // org/cluster with the same auth, and demanding a tool-specific prefix would force
4182
+ // operators to maintain duplicated env vars. Per Kern review on PR #306: the
4183
+ // inconsistency is deliberate, document it here so the next agent doesn't "fix" it.
2966
4184
  program
2967
4185
  .command("deploy")
2968
4186
  .description("Deploy Flair as a component to a remote Harper Fabric cluster")
@@ -3544,7 +4762,7 @@ bridge
3544
4762
  const cwd = opts.cwd ?? srcArg ?? process.cwd();
3545
4763
  const { discover } = await import("./bridges/discover.js");
3546
4764
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3547
- const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
4765
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3548
4766
  const { runImport } = await import("./bridges/runtime/import-runner.js");
3549
4767
  const { makeContext } = await import("./bridges/runtime/context.js");
3550
4768
  const { BridgeRuntimeError } = await import("./bridges/types.js");
@@ -3554,9 +4772,9 @@ bridge
3554
4772
  console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3555
4773
  process.exit(1);
3556
4774
  }
3557
- let descriptor;
4775
+ let loaded;
3558
4776
  try {
3559
- descriptor = await loadDescriptor(target);
4777
+ loaded = await loadBridge(target);
3560
4778
  }
3561
4779
  catch (err) {
3562
4780
  printBridgeError(err);
@@ -3591,10 +4809,10 @@ bridge
3591
4809
  if (ev.type === "done") {
3592
4810
  const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
3593
4811
  if (opts.dryRun) {
3594
- console.log(`\n${descriptor.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
4812
+ console.log(`\n${target.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
3595
4813
  }
3596
4814
  else {
3597
- console.log(`\n${descriptor.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
4815
+ console.log(`\n${target.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
3598
4816
  }
3599
4817
  return;
3600
4818
  }
@@ -3611,15 +4829,40 @@ bridge
3611
4829
  }
3612
4830
  };
3613
4831
  try {
3614
- await runImport({
3615
- descriptor,
3616
- cwd,
3617
- agentId,
3618
- dryRun: !!opts.dryRun,
3619
- putMemory,
3620
- onProgress,
3621
- ctx,
3622
- });
4832
+ if (loaded.kind === "yaml") {
4833
+ await runImport({
4834
+ bridgeName: target.name,
4835
+ descriptor: loaded.descriptor,
4836
+ cwd,
4837
+ agentId,
4838
+ dryRun: !!opts.dryRun,
4839
+ putMemory,
4840
+ onProgress,
4841
+ ctx,
4842
+ });
4843
+ }
4844
+ else {
4845
+ // Code plugin: invoke bridge.import(opts, ctx) directly; the plugin
4846
+ // returns an AsyncIterable of BridgeMemory that runImport processes.
4847
+ if (!loaded.plugin.import) {
4848
+ console.error(`Bridge "${name}" is a code plugin without an import() function — can only export through it.`);
4849
+ process.exit(1);
4850
+ }
4851
+ // Code-plugin options: pass through all --X flags as a single object.
4852
+ // The plugin's declared `options` descriptor validates what it actually cares about.
4853
+ const pluginOpts = { ...opts };
4854
+ const source = loaded.plugin.import(pluginOpts, ctx);
4855
+ await runImport({
4856
+ bridgeName: target.name,
4857
+ source,
4858
+ cwd,
4859
+ agentId,
4860
+ dryRun: !!opts.dryRun,
4861
+ putMemory,
4862
+ onProgress,
4863
+ ctx,
4864
+ });
4865
+ }
3623
4866
  }
3624
4867
  catch (err) {
3625
4868
  if (err instanceof BridgeRuntimeError) {
@@ -3651,7 +4894,7 @@ bridge
3651
4894
  const cwd = opts.cwd ?? dst;
3652
4895
  const { discover } = await import("./bridges/discover.js");
3653
4896
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3654
- const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
4897
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3655
4898
  const { runExport } = await import("./bridges/runtime/export-runner.js");
3656
4899
  const { makeContext } = await import("./bridges/runtime/context.js");
3657
4900
  const { BridgeRuntimeError } = await import("./bridges/types.js");
@@ -3661,18 +4904,22 @@ bridge
3661
4904
  console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3662
4905
  process.exit(1);
3663
4906
  }
3664
- let descriptor;
4907
+ let loaded;
3665
4908
  try {
3666
- descriptor = await loadDescriptor(target);
4909
+ loaded = await loadBridge(target);
3667
4910
  }
3668
4911
  catch (err) {
3669
4912
  printBridgeError(err);
3670
4913
  process.exit(1);
3671
4914
  }
3672
- if (!descriptor.export) {
4915
+ if (loaded.kind === "yaml" && !loaded.descriptor.export) {
3673
4916
  console.error(`Bridge "${name}" has no export block — cannot export through it.`);
3674
4917
  process.exit(1);
3675
4918
  }
4919
+ if (loaded.kind === "code" && !loaded.plugin.export) {
4920
+ console.error(`Bridge "${name}" is a code plugin without an export() function — can only import through it.`);
4921
+ process.exit(1);
4922
+ }
3676
4923
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3677
4924
  const ctx = makeContext({ bridge: name });
3678
4925
  // Memory fetcher — paginates GET /Memory?agentId=... applying any
@@ -3710,10 +4957,10 @@ bridge
3710
4957
  const onProgress = (ev) => {
3711
4958
  if (ev.type === "done") {
3712
4959
  if (opts.dryRun) {
3713
- console.log(`\n${descriptor.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
4960
+ console.log(`\n${target.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
3714
4961
  }
3715
4962
  else {
3716
- console.log(`\n${descriptor.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
4963
+ console.log(`\n${target.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
3717
4964
  }
3718
4965
  return;
3719
4966
  }
@@ -3733,15 +4980,28 @@ bridge
3733
4980
  process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
3734
4981
  };
3735
4982
  try {
3736
- await runExport({
3737
- descriptor,
3738
- cwd,
3739
- fetchMemories,
3740
- filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
3741
- dryRun: !!opts.dryRun,
3742
- ctx,
3743
- onProgress,
3744
- });
4983
+ if (loaded.kind === "yaml") {
4984
+ await runExport({
4985
+ descriptor: loaded.descriptor,
4986
+ cwd,
4987
+ fetchMemories,
4988
+ filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
4989
+ dryRun: !!opts.dryRun,
4990
+ ctx,
4991
+ onProgress,
4992
+ });
4993
+ }
4994
+ else {
4995
+ // Code plugin export: invoke plugin.export(memoryStream, opts, ctx) directly.
4996
+ // Plugin writes to its target however it likes (HTTP, file, etc.).
4997
+ if (opts.dryRun) {
4998
+ console.log(`${target.name}: dry-run not supported for code-plugin exports; aborting before invoking plugin.export().`);
4999
+ process.exit(2);
5000
+ }
5001
+ const pluginOpts = { ...opts };
5002
+ await loaded.plugin.export(fetchMemories({ agentId, subject: opts.subject, source: opts.source, since: opts.since }), pluginOpts, ctx);
5003
+ console.log(`${target.name}: code-plugin export completed. Record count not reported by the plugin.`);
5004
+ }
3745
5005
  }
3746
5006
  catch (err) {
3747
5007
  if (err instanceof BridgeRuntimeError) {
@@ -3762,7 +5022,7 @@ bridge
3762
5022
  const cwd = opts.cwd ?? process.cwd();
3763
5023
  const { discover } = await import("./bridges/discover.js");
3764
5024
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3765
- const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
5025
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3766
5026
  const { runRoundTrip } = await import("./bridges/runtime/roundtrip.js");
3767
5027
  const { BridgeRuntimeError } = await import("./bridges/types.js");
3768
5028
  const found = await discover({ builtins: builtinDiscoveryRecords() });
@@ -3771,25 +5031,30 @@ bridge
3771
5031
  console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3772
5032
  process.exit(1);
3773
5033
  }
3774
- let descriptor;
5034
+ let loaded;
3775
5035
  try {
3776
- descriptor = await loadDescriptor(target);
5036
+ loaded = await loadBridge(target);
3777
5037
  }
3778
5038
  catch (err) {
3779
5039
  printBridgeError(err);
3780
5040
  process.exit(1);
3781
5041
  }
5042
+ if (loaded.kind === "code") {
5043
+ console.error(`Bridge "${name}" is a code plugin — round-trip testing for code plugins lands in slice 3d (requires mocked fetch transport).`);
5044
+ console.error(`For now, code plugins should ship their own tests alongside the npm package.`);
5045
+ process.exit(2);
5046
+ }
3782
5047
  try {
3783
- const result = await runRoundTrip({ descriptor, cwd, fixturePath: opts.fixture });
5048
+ const result = await runRoundTrip({ descriptor: loaded.descriptor, cwd, fixturePath: opts.fixture });
3784
5049
  if (opts.json) {
3785
5050
  console.log(JSON.stringify(result, null, 2));
3786
5051
  process.exit(result.passed ? 0 : 1);
3787
5052
  }
3788
5053
  if (result.passed) {
3789
- console.log(`✅ ${descriptor.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
5054
+ console.log(`✅ ${target.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
3790
5055
  process.exit(0);
3791
5056
  }
3792
- console.log(`❌ ${descriptor.name} round-trip failed.`);
5057
+ console.log(`❌ ${target.name} round-trip failed.`);
3793
5058
  console.log(` expected ${result.expectedCount} records, got ${result.actualCount} back.`);
3794
5059
  if (result.missingInPass2.length > 0) {
3795
5060
  console.log(` missing from re-import (${result.missingInPass2.length}):`);
@@ -3825,11 +5090,92 @@ bridge
3825
5090
  process.exit(1);
3826
5091
  }
3827
5092
  });
5093
+ bridge
5094
+ .command("allow <name>")
5095
+ .description("Approve an npm code-plugin bridge for execution. Approval is pinned to the package's location and package.json contents — a malicious package squatting on the same name in a different node_modules tree will be refused at load-time.")
5096
+ .action(async (name) => {
5097
+ const { discover } = await import("./bridges/discover.js");
5098
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
5099
+ const { allow } = await import("./bridges/runtime/allow-list.js");
5100
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
5101
+ const target = found.find((b) => b.name === name);
5102
+ if (!target) {
5103
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
5104
+ process.exit(1);
5105
+ }
5106
+ if (target.source !== "npm-package") {
5107
+ console.error(`"${name}" is a ${target.source} bridge; only npm code plugins require allow-list approval.`);
5108
+ console.error(`YAML and built-in bridges run via the descriptor runtime and don't execute arbitrary JS.`);
5109
+ process.exit(1);
5110
+ }
5111
+ try {
5112
+ const result = await allow(name, target.path);
5113
+ if (result.alreadyAllowed) {
5114
+ console.log(`${name} was already allowed at ${result.entry.packageDir} — no change.`);
5115
+ return;
5116
+ }
5117
+ const verb = result.updated ? "re-approved" : "allowed";
5118
+ console.log(`✓ ${name} ${verb}.`);
5119
+ console.log(` location: ${result.entry.packageDir}`);
5120
+ console.log(` version: ${result.entry.version ?? "(not declared)"}`);
5121
+ console.log(` digest: ${result.entry.packageJsonSha256.slice(0, 16)}…`);
5122
+ console.log(` If the package later moves or its package.json content changes, execution is refused until you re-run this command.`);
5123
+ console.log(` Revoke anytime with: flair bridge revoke ${name}`);
5124
+ }
5125
+ catch (err) {
5126
+ console.error(`Failed to approve "${name}": ${err?.message ?? err}`);
5127
+ process.exit(1);
5128
+ }
5129
+ });
5130
+ bridge
5131
+ .command("revoke <name>")
5132
+ .description("Revoke approval for an npm code-plugin bridge (future invocations will require `flair bridge allow <name>` again)")
5133
+ .action(async (name) => {
5134
+ const { revoke } = await import("./bridges/runtime/allow-list.js");
5135
+ const result = await revoke(name);
5136
+ if (!result.wasAllowed) {
5137
+ console.log(`${name} was not on the allow-list — no change.`);
5138
+ return;
5139
+ }
5140
+ console.log(`✓ ${name} revoked. Future invocations require \`flair bridge allow ${name}\` again.`);
5141
+ });
5142
+ bridge
5143
+ .command("allow-list")
5144
+ .description("Show the allow-listed code-plugin bridges")
5145
+ .option("--json", "Emit raw JSON")
5146
+ .action(async (opts) => {
5147
+ const { list: listAllowed } = await import("./bridges/runtime/allow-list.js");
5148
+ const entries = await listAllowed();
5149
+ if (opts.json) {
5150
+ console.log(JSON.stringify(entries, null, 2));
5151
+ return;
5152
+ }
5153
+ if (entries.length === 0) {
5154
+ console.log("No code-plugin bridges are allow-listed yet.");
5155
+ console.log("Allow one with: flair bridge allow <name>");
5156
+ return;
5157
+ }
5158
+ const nameW = Math.max(4, ...entries.map((e) => e.name.length));
5159
+ const verW = Math.max(7, ...entries.map((e) => (e.version ?? "—").length));
5160
+ console.log(` ${"name".padEnd(nameW)} ${"version".padEnd(verW)} allowed-at location / digest`);
5161
+ for (const e of entries) {
5162
+ console.log(` ${e.name.padEnd(nameW)} ${(e.version ?? "—").padEnd(verW)} ${e.allowedAt} ${e.packageDir}`);
5163
+ console.log(` ${" ".repeat(nameW)} ${" ".repeat(verW)} ${" ".repeat(24)} sha256:${e.packageJsonSha256.slice(0, 16)}…`);
5164
+ }
5165
+ });
3828
5166
  function printBridgeError(err) {
3829
5167
  // Pretty-print BridgeRuntimeError as the structured shape from §10 of the
3830
5168
  // spec, plus a one-line human summary so the operator gets both.
3831
5169
  const detail = err?.detail;
3832
5170
  if (detail && typeof detail === "object") {
5171
+ // Trust-check failures get a dedicated, operator-facing rendering.
5172
+ // Dumping the full spec-§10 JSON is useful when an operator is
5173
+ // debugging a broken YAML descriptor; for trust errors it buries the
5174
+ // one thing that matters — the command to re-approve.
5175
+ if (detail.field === "(trust)") {
5176
+ printTrustError(detail);
5177
+ return;
5178
+ }
3833
5179
  console.error(`Bridge error: ${detail.hint ?? err.message}`);
3834
5180
  console.error(JSON.stringify(detail, null, 2));
3835
5181
  }
@@ -3837,6 +5183,73 @@ function printBridgeError(err) {
3837
5183
  console.error(`Bridge error: ${err.message ?? String(err)}`);
3838
5184
  }
3839
5185
  }
5186
+ function printTrustError(detail) {
5187
+ const name = detail.bridge ?? "(unknown)";
5188
+ const ctx = detail.context ?? {};
5189
+ const reapprove = ` flair bridge allow ${name}`;
5190
+ const bar = "─".repeat(60);
5191
+ const header = (title) => {
5192
+ console.error("");
5193
+ console.error(`⚠ ${title} — ${name}`);
5194
+ console.error(bar);
5195
+ };
5196
+ const footer = (label) => {
5197
+ console.error("");
5198
+ console.error(`${label}:`);
5199
+ console.error(reapprove);
5200
+ console.error("");
5201
+ };
5202
+ switch (detail.got) {
5203
+ case "not-allowed":
5204
+ header("Approval required");
5205
+ console.error("This bridge is an npm code plugin — it runs arbitrary JavaScript.");
5206
+ console.error("First-use approval is required before Flair will execute it.");
5207
+ footer("Approve it with");
5208
+ return;
5209
+ case "path-mismatch":
5210
+ header("Trust check failed: package location changed");
5211
+ console.error("A different package with the same name was discovered. This is how");
5212
+ console.error("local squatting attacks present — a planted `node_modules/flair-bridge-*`");
5213
+ console.error("in an unrelated project tree.");
5214
+ console.error("");
5215
+ console.error(` approved: ${ctx.approvedPath ?? "(unknown)"}`);
5216
+ console.error(` version ${ctx.approvedVersion ?? "?"} at ${ctx.approvedAt ?? "?"}`);
5217
+ console.error(` now: ${ctx.observedPath ?? "(unknown)"}`);
5218
+ footer("If the new location is intentional, re-approve");
5219
+ return;
5220
+ case "digest-mismatch":
5221
+ header("Trust check failed: package contents changed");
5222
+ console.error("The package.json at the approved location has changed since you");
5223
+ console.error("approved this bridge. This fires on every upgrade — it's a trust");
5224
+ console.error("event, not an error. If the update is intentional, re-approve.");
5225
+ console.error("");
5226
+ console.error(` location: ${ctx.packagePath ?? "(unknown)"}`);
5227
+ console.error(` approved version: ${ctx.approvedVersion ?? "?"} (at ${ctx.approvedAt ?? "?"})`);
5228
+ console.error(` approved digest: sha256:${(ctx.approvedDigest ?? "").slice(0, 16)}…`);
5229
+ console.error(` observed digest: sha256:${(ctx.observedDigest ?? "").slice(0, 16)}…`);
5230
+ footer("Re-approve");
5231
+ return;
5232
+ case "entry-incomplete":
5233
+ header("Trust check failed: approval record is incomplete");
5234
+ console.error("The allow-list entry for this bridge is missing a location or digest.");
5235
+ console.error("This usually means the record was created by a pre-fix Flair version");
5236
+ console.error("(0.6.0 / 0.6.1) that only stored the name. Re-approve to upgrade.");
5237
+ footer("Re-approve");
5238
+ return;
5239
+ case "package-missing":
5240
+ header("Trust check failed: approved package missing on disk");
5241
+ console.error("The package location recorded at allow-time is no longer readable.");
5242
+ console.error("");
5243
+ console.error(` approved at: ${ctx.approvedPath ?? "(unknown)"}`);
5244
+ console.error(` discovered: ${ctx.discoveredPath ?? "(unknown)"}`);
5245
+ footer("Reinstall the package, then re-approve");
5246
+ return;
5247
+ default:
5248
+ // Unknown trust sub-reason — fall back to the raw structured print.
5249
+ console.error(`Bridge error (trust): ${detail.hint ?? detail.got ?? "unknown"}`);
5250
+ console.error(JSON.stringify(detail, null, 2));
5251
+ }
5252
+ }
3840
5253
  // ─── flair backup ────────────────────────────────────────────────────────────
3841
5254
  program
3842
5255
  .command("backup")
@@ -4246,9 +5659,264 @@ program
4246
5659
  console.log(`Souls: ${(data.souls ?? []).length}`);
4247
5660
  }
4248
5661
  });
5662
+ // ─── flair migrate-harness-memory ───────────────────────────────────────────
5663
+ /** Resolve the memory directory path for a target harness. */
5664
+ function resolveMemoryDir(target, agentId) {
5665
+ switch (target) {
5666
+ case "claude-code": {
5667
+ const cwd = process.cwd();
5668
+ const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
5669
+ const memoryDir = join(homedir(), ".claude", "projects", encodedCwd, "memory");
5670
+ const resolved = resolve(memoryDir);
5671
+ const expectedRoot = join(homedir(), ".claude", "projects");
5672
+ if (!resolved.startsWith(expectedRoot + sep)) {
5673
+ throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
5674
+ }
5675
+ return resolved;
5676
+ }
5677
+ case "openclaw": {
5678
+ const cwd = process.cwd();
5679
+ const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
5680
+ const memoryDir = join(homedir(), ".openclaw", "projects", encodedCwd, "memory");
5681
+ const resolved = resolve(memoryDir);
5682
+ const expectedRoot = join(homedir(), ".openclaw", "projects");
5683
+ if (!resolved.startsWith(expectedRoot + sep)) {
5684
+ throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
5685
+ }
5686
+ return resolved;
5687
+ }
5688
+ case "pi": {
5689
+ const cwd = process.cwd();
5690
+ const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
5691
+ const memoryDir = join(homedir(), ".pi", "projects", encodedCwd, "memory");
5692
+ const resolved = resolve(memoryDir);
5693
+ const expectedRoot = join(homedir(), ".pi", "projects");
5694
+ if (!resolved.startsWith(expectedRoot + sep)) {
5695
+ throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
5696
+ }
5697
+ return resolved;
5698
+ }
5699
+ default:
5700
+ throw new Error(`Unknown target: ${target}. Valid: claude-code, openclaw, pi`);
5701
+ }
5702
+ }
5703
+ /** Parse a memory file with YAML frontmatter. */
5704
+ function parseMemoryFile(filePath) {
5705
+ const raw = readFileSync(filePath, "utf-8");
5706
+ const lines = raw.split("\n");
5707
+ if (!lines[0].startsWith("---")) {
5708
+ return { meta: {}, body: raw };
5709
+ }
5710
+ let endIdx = 1;
5711
+ while (endIdx < lines.length && !lines[endIdx].startsWith("---")) {
5712
+ endIdx++;
5713
+ }
5714
+ const frontmatterLines = lines.slice(1, endIdx);
5715
+ const bodyLines = lines.slice(endIdx + 1);
5716
+ const yamlContent = frontmatterLines.join("\n");
5717
+ const meta = parseYaml(yamlContent) || {};
5718
+ return {
5719
+ meta: {
5720
+ name: meta.name,
5721
+ description: meta.description,
5722
+ type: meta.type,
5723
+ tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []),
5724
+ },
5725
+ body: bodyLines.join("\n").trim(),
5726
+ };
5727
+ }
5728
+ /** Map memory type to durability. */
5729
+ function mapDurability(type) {
5730
+ switch (type) {
5731
+ case "feedback":
5732
+ case "reference":
5733
+ return "permanent";
5734
+ case "project":
5735
+ case "user":
5736
+ return "persistent";
5737
+ default:
5738
+ return "standard";
5739
+ }
5740
+ }
5741
+ /** Extract keywords from filename for tags. */
5742
+ function extractKeywordsFromFilename(filename) {
5743
+ const base = filename.replace(/\.md$/, "");
5744
+ const withoutType = base.replace(/^(feedback|project|reference|user)_/, "");
5745
+ const parts = withoutType.split(/[_-]+/).map(p => p.toLowerCase());
5746
+ const stopwords = new Set(["the", "and", "for", "with", "about", "on", "in", "to", "of", "a", "an"]);
5747
+ return parts.filter(p => p.length > 2 && !stopwords.has(p));
5748
+ }
5749
+ program
5750
+ .command("migrate-harness-memory")
5751
+ .description("Migrate harness-local memories to Flair")
5752
+ .requiredOption("--target <target>", "Target harness: claude-code, openclaw, pi")
5753
+ .requiredOption("--agent <id>", "Agent ID to write memories under")
5754
+ .option("--dry-run", "Show what would be migrated without writing")
5755
+ .action(async (opts) => {
5756
+ const target = opts.target;
5757
+ const agentId = opts.agent;
5758
+ const dryRun = !!opts.dryRun;
5759
+ let memoryDir;
5760
+ try {
5761
+ memoryDir = resolveMemoryDir(target, agentId);
5762
+ }
5763
+ catch (e) {
5764
+ console.error(`Error resolving memory directory: ${e.message}`);
5765
+ process.exit(1);
5766
+ }
5767
+ if (!existsSync(memoryDir)) {
5768
+ console.error(`Error: Memory directory not found: ${memoryDir}`);
5769
+ process.exit(1);
5770
+ }
5771
+ const migratedDir = join(memoryDir, ".migrated");
5772
+ if (!dryRun) {
5773
+ mkdirSync(migratedDir, { recursive: true, mode: 0o700 });
5774
+ }
5775
+ console.log(`Migrating memories from ${memoryDir} to Flair (agentId=${agentId})`);
5776
+ if (dryRun)
5777
+ console.log(" (DRY RUN - no files will be modified)");
5778
+ const files = readdirSync(memoryDir, { withFileTypes: true })
5779
+ .filter(d => d.isFile() && d.name.endsWith(".md") && d.name !== "MEMORY.md")
5780
+ .map(d => d.name)
5781
+ .sort();
5782
+ if (files.length === 0) {
5783
+ console.log("No memory files found.");
5784
+ return;
5785
+ }
5786
+ console.log(`Found ${files.length} memory file(s) to migrate.`);
5787
+ let successCount = 0;
5788
+ let skipCount = 0;
5789
+ let failCount = 0;
5790
+ for (const filename of files) {
5791
+ const sourcePath = join(memoryDir, filename);
5792
+ const migratedPath = join(migratedDir, filename);
5793
+ if (existsSync(migratedPath)) {
5794
+ console.log(` ${filename}: already migrated, skipping`);
5795
+ skipCount++;
5796
+ continue;
5797
+ }
5798
+ console.log(` Processing: ${filename}...`);
5799
+ let parsed;
5800
+ try {
5801
+ parsed = parseMemoryFile(sourcePath);
5802
+ }
5803
+ catch (e) {
5804
+ console.error(` Failed to parse: ${e.message}`);
5805
+ failCount++;
5806
+ continue;
5807
+ }
5808
+ let type = parsed.meta.type;
5809
+ if (!type) {
5810
+ if (filename.startsWith("feedback_"))
5811
+ type = "feedback";
5812
+ else if (filename.startsWith("project_"))
5813
+ type = "project";
5814
+ else if (filename.startsWith("reference_"))
5815
+ type = "reference";
5816
+ else
5817
+ type = "session";
5818
+ }
5819
+ const durability = mapDurability(type);
5820
+ const tags = [...(parsed.meta.tags || [])];
5821
+ const filenameTags = extractKeywordsFromFilename(filename);
5822
+ for (const t of filenameTags) {
5823
+ if (!tags.includes(t))
5824
+ tags.push(t);
5825
+ }
5826
+ tags.push(type);
5827
+ const content = parsed.body;
5828
+ console.log(` Type: ${type}, Durability: ${durability}`);
5829
+ console.log(` Tags: ${tags.join(", ")}`);
5830
+ console.log(` Content preview: ${content.slice(0, 100)}${content.length > 100 ? "..." : ""}`);
5831
+ if (!dryRun) {
5832
+ console.log(` Writing to Flair...`);
5833
+ try {
5834
+ const DEFAULT_PORT = 19926;
5835
+ const httpUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
5836
+ const agentKeyId = `${agentId}.key`;
5837
+ const keysDir = join(homedir(), ".flair", "keys");
5838
+ const keyPath = join(keysDir, agentKeyId);
5839
+ let authSuccess = false;
5840
+ if (existsSync(keyPath)) {
5841
+ const memoryId = `${agentId}-${randomUUID()}`;
5842
+ const body = {
5843
+ id: memoryId,
5844
+ agentId: agentId,
5845
+ content: content,
5846
+ type: type,
5847
+ durability: durability,
5848
+ tags: tags,
5849
+ createdAt: new Date().toISOString(),
5850
+ };
5851
+ const memoryPath = `/Memory/${memoryId}`;
5852
+ const res = await authFetch(httpUrl, agentId, keyPath, "PUT", memoryPath, body);
5853
+ if (res.ok) {
5854
+ authSuccess = true;
5855
+ console.log(` Write to Flair successful (Ed25519 auth)`);
5856
+ }
5857
+ }
5858
+ if (!authSuccess) {
5859
+ const adminPass = process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD || "";
5860
+ if (adminPass) {
5861
+ const memoryId = `${agentId}-${randomUUID()}`;
5862
+ const body = {
5863
+ id: memoryId,
5864
+ agentId: agentId,
5865
+ content: content,
5866
+ type: type,
5867
+ durability: durability,
5868
+ tags: tags,
5869
+ createdAt: new Date().toISOString(),
5870
+ };
5871
+ const memoryPath = `/Memory/${memoryId}`;
5872
+ const auth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
5873
+ const res = await fetch(`${httpUrl}${memoryPath}`, {
5874
+ method: "PUT",
5875
+ headers: {
5876
+ Authorization: auth,
5877
+ "Content-Type": "application/json",
5878
+ },
5879
+ body: JSON.stringify(body),
5880
+ signal: AbortSignal.timeout(10000),
5881
+ });
5882
+ if (res.ok) {
5883
+ console.log(` Write to Flair successful (Basic auth)`);
5884
+ }
5885
+ else {
5886
+ const text = await res.text();
5887
+ throw new Error(`HTTP ${res.status}: ${text}`);
5888
+ }
5889
+ }
5890
+ else {
5891
+ throw new Error("No authentication method available");
5892
+ }
5893
+ }
5894
+ renameSync(sourcePath, migratedPath);
5895
+ console.log(` Moved to .migrated/${filename}`);
5896
+ }
5897
+ catch (e) {
5898
+ console.error(` Failed: ${e.message}`);
5899
+ failCount++;
5900
+ continue;
5901
+ }
5902
+ }
5903
+ else {
5904
+ console.log(` [dry-run] Would write to Flair and move to .migrated/${filename}`);
5905
+ }
5906
+ successCount++;
5907
+ }
5908
+ console.log(`\nMigration complete:`);
5909
+ console.log(` Processed: ${files.length}`);
5910
+ console.log(` Successful: ${successCount}`);
5911
+ console.log(` Skipped: ${skipCount}`);
5912
+ console.log(` Failed: ${failCount}`);
5913
+ if (failCount > 0) {
5914
+ process.exit(1);
5915
+ }
5916
+ });
4249
5917
  // Run CLI only when this is the entry point (not when imported for testing)
4250
5918
  if (import.meta.main) {
4251
5919
  await program.parseAsync();
4252
5920
  }
4253
5921
  // ─── Exported for testing ─────────────────────────────────────────────────────
4254
- export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, signRequestBody, b64, b64url, program, };
5922
+ export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, };