@tpsdev-ai/flair 0.4.15 → 0.5.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
@@ -6,6 +6,8 @@ import { homedir } from "node:os";
6
6
  import { join, resolve as resolvePath } from "node:path";
7
7
  import { spawn } from "node:child_process";
8
8
  import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
9
+ import { keystore } from "./keystore.js";
10
+ import { signBody } from "../resources/federation-crypto.js";
9
11
  // ─── Defaults ────────────────────────────────────────────────────────────────
10
12
  const DEFAULT_PORT = 19926;
11
13
  const DEFAULT_OPS_PORT = 19925;
@@ -127,6 +129,11 @@ async function api(method, path, body) {
127
129
  if (token) {
128
130
  authHeader = `Bearer ${token}`;
129
131
  }
132
+ else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
133
+ // Admin Basic auth — used by federation, backup, and other admin CLI commands
134
+ const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
135
+ authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
136
+ }
130
137
  else {
131
138
  // Extract agentId from body (POST/PUT) or URL query params (GET)
132
139
  let agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
@@ -357,14 +364,37 @@ program
357
364
  // Only applied to install — run needs real HOME for npm/node resolution.
358
365
  const installEnv = { ...env, HOME: join(dataDir, "..") };
359
366
  console.log("Installing Harper...");
367
+ console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
360
368
  await new Promise((resolve, reject) => {
361
369
  let output = "";
370
+ let dotTimer = null;
362
371
  const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
372
+ // Print progress dots so the terminal doesn't appear frozen during model download
373
+ dotTimer = setInterval(() => process.stdout.write("."), 3000);
363
374
  install.stdout?.on("data", (d) => { output += d.toString(); });
364
375
  install.stderr?.on("data", (d) => { output += d.toString(); });
365
- install.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`)));
366
- install.on("error", reject);
367
- setTimeout(() => { install.kill(); reject(new Error(`Harper install timed out: ${output}`)); }, 60_000);
376
+ install.on("exit", (code) => {
377
+ if (dotTimer) {
378
+ clearInterval(dotTimer);
379
+ process.stdout.write("\n");
380
+ }
381
+ code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
382
+ });
383
+ install.on("error", (err) => {
384
+ if (dotTimer) {
385
+ clearInterval(dotTimer);
386
+ process.stdout.write("\n");
387
+ }
388
+ reject(err);
389
+ });
390
+ setTimeout(() => {
391
+ install.kill();
392
+ if (dotTimer) {
393
+ clearInterval(dotTimer);
394
+ process.stdout.write("\n");
395
+ }
396
+ reject(new Error(`Harper install timed out: ${output}`));
397
+ }, 60_000);
368
398
  });
369
399
  }
370
400
  // Start Harper with flair loaded as a component (the "." arg).
@@ -473,8 +503,14 @@ program
473
503
  console.log(` Flair URL: ${httpUrl}`);
474
504
  console.log(` Private key: ${privPath}`);
475
505
  if (!opts.adminPass && !alreadyRunning) {
476
- console.log(`\n⚠️ Admin password (save this — it won't be shown again):`);
477
- console.log(` ${adminPass}`);
506
+ console.log(`\n ┌─────────────────────────────────────────────────┐`);
507
+ console.log(` │ Harper admin credentials (save these now): │`);
508
+ console.log(` │ │`);
509
+ console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
510
+ console.log(` │ Password: ${adminPass.padEnd(37)}│`);
511
+ console.log(` │ │`);
512
+ console.log(` │ ⚠️ The password won't be shown again. │`);
513
+ console.log(` └─────────────────────────────────────────────────┘`);
478
514
  }
479
515
  console.log(`\n Export: FLAIR_URL=${httpUrl}`);
480
516
  // ── First-run soul setup ──────────────────────────────────────────────
@@ -484,7 +520,25 @@ program
484
520
  console.log("\n🎭 Set up agent personality (press Enter to skip any):\n");
485
521
  const { createInterface } = await import("node:readline");
486
522
  const rl = createInterface({ input: process.stdin, output: process.stdout });
487
- const ask = (q) => new Promise(r => rl.question(q, r));
523
+ // Buffered ask: collects rapid input (pasted text) into one answer.
524
+ // Waits 200ms after last line before resolving, so pasted multiline
525
+ // blocks are captured as a single answer instead of spilling across prompts.
526
+ const ask = (q) => new Promise(resolve => {
527
+ let buffer = "";
528
+ let timer = null;
529
+ const finish = () => {
530
+ rl.removeListener("line", onLine);
531
+ resolve(buffer.trim());
532
+ };
533
+ const onLine = (line) => {
534
+ buffer += (buffer ? "\n" : "") + line;
535
+ if (timer)
536
+ clearTimeout(timer);
537
+ timer = setTimeout(finish, 200);
538
+ };
539
+ process.stdout.write(q);
540
+ rl.on("line", onLine);
541
+ });
488
542
  const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
489
543
  const project = await ask(" What project is it working on?\n > ");
490
544
  const standards = await ask(" Any coding standards or preferences?\n > ");
@@ -514,6 +568,25 @@ program
514
568
  console.log("\n No soul entries — you can add them later with: flair soul set --agent " + agentId + " --key role --value \"...\"");
515
569
  }
516
570
  }
571
+ else {
572
+ // --skip-soul or non-interactive: seed sensible defaults so bootstrap returns useful context
573
+ const defaultSoulEntries = [
574
+ ["role", "AI assistant [default — customize with 'flair soul set']"],
575
+ ["personality", "Helpful, precise, and proactive [default — customize with 'flair soul set']"],
576
+ ["constraints", "Respect user privacy. Be concise. [default — customize with 'flair soul set']"],
577
+ ];
578
+ console.log("\nSeeding default soul entries...");
579
+ for (const [key, value] of defaultSoulEntries) {
580
+ try {
581
+ await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
582
+ console.log(` ✓ soul:${key} set (default)`);
583
+ }
584
+ catch (err) {
585
+ console.warn(` ⚠ soul:${key} failed: ${err.message}`);
586
+ }
587
+ }
588
+ console.log(` Customize with: flair soul set --agent ${agentId} --key role --value "..."`);
589
+ }
517
590
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
518
591
  console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
519
592
  // Auto-wire MCP config into ~/.claude.json if Claude Code is installed
@@ -816,6 +889,373 @@ agent
816
889
  }
817
890
  console.log(`\n✅ Agent '${id}' removed successfully`);
818
891
  });
892
+ // ─── flair principal ─────────────────────────────────────────────────────────
893
+ // 1.0 identity management. The Principal model extends Agent — this is the
894
+ // preferred CLI surface for managing identities going forward.
895
+ const principal = program.command("principal").description("Manage principals (humans and agents)");
896
+ principal
897
+ .command("add <id>")
898
+ .description("Create a new principal")
899
+ .option("--kind <kind>", "Principal kind: human or agent", "agent")
900
+ .option("--name <name>", "Display name (defaults to id)")
901
+ .option("--admin", "Grant admin privileges")
902
+ .option("--trust <tier>", "Default trust tier: endorsed, corroborated, or unverified")
903
+ .option("--runtime <runtime>", "Runtime: openclaw, claude-code, headless, external")
904
+ .option("--port <port>", "Harper HTTP port")
905
+ .option("--admin-pass <pass>", "Admin password for registration")
906
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
907
+ .option("--ops-port <port>", "Harper operations API port")
908
+ .action(async (id, opts) => {
909
+ const opsPort = resolveOpsPort(opts);
910
+ const keysDir = opts.keysDir ?? defaultKeysDir();
911
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS;
912
+ const adminUser = DEFAULT_ADMIN_USER;
913
+ const kind = opts.kind ?? "agent";
914
+ const name = opts.name ?? id;
915
+ const isAdmin = opts.admin ?? false;
916
+ const trustTier = opts.trust ?? (isAdmin ? "endorsed" : "unverified");
917
+ const runtime = opts.runtime;
918
+ if (!adminPass) {
919
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
920
+ process.exit(1);
921
+ }
922
+ // Generate Ed25519 keypair (agents always get one; humans get one for instance-attestation)
923
+ mkdirSync(keysDir, { recursive: true });
924
+ const privPath = privKeyPath(id, keysDir);
925
+ const pubPath = pubKeyPath(id, keysDir);
926
+ let pubKeyB64url;
927
+ if (existsSync(privPath)) {
928
+ console.log(`Reusing existing key: ${privPath}`);
929
+ const seed = new Uint8Array(readFileSync(privPath));
930
+ const kp = nacl.sign.keyPair.fromSeed(seed);
931
+ pubKeyB64url = b64url(kp.publicKey);
932
+ }
933
+ else {
934
+ const kp = nacl.sign.keyPair();
935
+ const seed = kp.secretKey.slice(0, 32);
936
+ writeFileSync(privPath, Buffer.from(seed));
937
+ chmodSync(privPath, 0o600);
938
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
939
+ pubKeyB64url = b64url(kp.publicKey);
940
+ console.log(`Keypair written: ${privPath}`);
941
+ }
942
+ // Insert via operations API with Principal fields
943
+ const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
944
+ const record = {
945
+ id,
946
+ name,
947
+ displayName: name,
948
+ kind,
949
+ type: kind === "human" ? "human" : "agent",
950
+ status: "active",
951
+ publicKey: pubKeyB64url,
952
+ defaultTrustTier: trustTier,
953
+ admin: isAdmin,
954
+ runtime: runtime ?? null,
955
+ createdAt: new Date().toISOString(),
956
+ updatedAt: new Date().toISOString(),
957
+ };
958
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
959
+ method: "POST",
960
+ headers: { "Content-Type": "application/json", Authorization: auth },
961
+ body: JSON.stringify({ operation: "upsert", database: "flair", table: "Agent", records: [record] }),
962
+ });
963
+ if (!res.ok) {
964
+ const text = await res.text().catch(() => "");
965
+ console.error(`Error: ${res.status} ${text}`);
966
+ process.exit(1);
967
+ }
968
+ console.log(`✅ Principal '${id}' created`);
969
+ console.log(` Kind: ${kind}`);
970
+ console.log(` Trust: ${trustTier}`);
971
+ console.log(` Admin: ${isAdmin}`);
972
+ if (runtime)
973
+ console.log(` Runtime: ${runtime}`);
974
+ console.log(` Public key: ${pubKeyB64url}`);
975
+ console.log(` Private key: ${privPath}`);
976
+ });
977
+ principal
978
+ .command("list")
979
+ .description("List all principals")
980
+ .option("--kind <kind>", "Filter by kind: human or agent")
981
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
982
+ .option("--port <port>", "Harper HTTP port")
983
+ .option("--ops-port <port>", "Harper operations API port")
984
+ .action(async (opts) => {
985
+ const opsPort = resolveOpsPort(opts);
986
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
987
+ if (!adminPass) {
988
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
989
+ process.exit(1);
990
+ }
991
+ const kindFilter = opts.kind ? ` WHERE kind = '${opts.kind}'` : "";
992
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
993
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
994
+ method: "POST",
995
+ headers: { "Content-Type": "application/json", Authorization: auth },
996
+ body: JSON.stringify({
997
+ operation: "sql",
998
+ sql: `SELECT id, name, kind, status, defaultTrustTier, admin, runtime, createdAt FROM flair.Agent${kindFilter} ORDER BY createdAt`,
999
+ }),
1000
+ });
1001
+ if (!res.ok) {
1002
+ const text = await res.text().catch(() => "");
1003
+ console.error(`Error: ${res.status} ${text}`);
1004
+ process.exit(1);
1005
+ }
1006
+ const records = await res.json();
1007
+ if (records.length === 0) {
1008
+ console.log("No principals found.");
1009
+ return;
1010
+ }
1011
+ // Table format
1012
+ console.log(`${"ID".padEnd(20)} ${"Kind".padEnd(7)} ${"Trust".padEnd(14)} ${"Admin".padEnd(6)} ${"Status".padEnd(12)} ${"Runtime".padEnd(12)} Created`);
1013
+ console.log("─".repeat(95));
1014
+ for (const r of records) {
1015
+ const kind = r.kind ?? "agent";
1016
+ const trust = r.defaultTrustTier ?? "—";
1017
+ const admin = r.admin ? "yes" : "no";
1018
+ const status = r.status ?? "active";
1019
+ const runtime = r.runtime ?? "—";
1020
+ const created = r.createdAt?.slice(0, 10) ?? "—";
1021
+ console.log(`${String(r.id).padEnd(20)} ${kind.padEnd(7)} ${trust.padEnd(14)} ${admin.padEnd(6)} ${status.padEnd(12)} ${runtime.padEnd(12)} ${created}`);
1022
+ }
1023
+ });
1024
+ principal
1025
+ .command("show <id>")
1026
+ .description("Show principal details")
1027
+ .action(async (id) => {
1028
+ const result = await api("GET", `/Agent/${id}`);
1029
+ console.log(JSON.stringify(result, null, 2));
1030
+ });
1031
+ principal
1032
+ .command("disable <id>")
1033
+ .description("Deactivate a principal (revokes access, preserves data)")
1034
+ .option("--admin-pass <pass>", "Admin password")
1035
+ .option("--ops-port <port>", "Harper operations API port")
1036
+ .action(async (id, opts) => {
1037
+ const opsPort = resolveOpsPort(opts);
1038
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1039
+ if (!adminPass) {
1040
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1041
+ process.exit(1);
1042
+ }
1043
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1044
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1045
+ method: "POST",
1046
+ headers: { "Content-Type": "application/json", Authorization: auth },
1047
+ body: JSON.stringify({
1048
+ operation: "update",
1049
+ database: "flair",
1050
+ table: "Agent",
1051
+ records: [{ id, status: "deactivated", updatedAt: new Date().toISOString() }],
1052
+ }),
1053
+ });
1054
+ if (!res.ok) {
1055
+ const text = await res.text().catch(() => "");
1056
+ console.error(`Error: ${res.status} ${text}`);
1057
+ process.exit(1);
1058
+ }
1059
+ console.log(`✅ Principal '${id}' deactivated`);
1060
+ });
1061
+ principal
1062
+ .command("promote <id> <tier>")
1063
+ .description("Change a principal's trust tier (endorsed, corroborated, unverified)")
1064
+ .option("--admin-pass <pass>", "Admin password")
1065
+ .option("--ops-port <port>", "Harper operations API port")
1066
+ .action(async (id, tier, opts) => {
1067
+ const validTiers = ["endorsed", "corroborated", "unverified"];
1068
+ if (!validTiers.includes(tier)) {
1069
+ console.error(`Error: tier must be one of: ${validTiers.join(", ")}`);
1070
+ process.exit(1);
1071
+ }
1072
+ const opsPort = resolveOpsPort(opts);
1073
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1074
+ if (!adminPass) {
1075
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1076
+ process.exit(1);
1077
+ }
1078
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1079
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1080
+ method: "POST",
1081
+ headers: { "Content-Type": "application/json", Authorization: auth },
1082
+ body: JSON.stringify({
1083
+ operation: "update",
1084
+ database: "flair",
1085
+ table: "Agent",
1086
+ records: [{ id, defaultTrustTier: tier, updatedAt: new Date().toISOString() }],
1087
+ }),
1088
+ });
1089
+ if (!res.ok) {
1090
+ const text = await res.text().catch(() => "");
1091
+ console.error(`Error: ${res.status} ${text}`);
1092
+ process.exit(1);
1093
+ }
1094
+ console.log(`✅ Principal '${id}' trust tier set to '${tier}'`);
1095
+ });
1096
+ // ─── flair idp ───────────────────────────────────────────────────────────────
1097
+ // XAA Enterprise IdP configuration (per FLAIR-XAA spec § 4).
1098
+ const idp = program.command("idp").description("Manage enterprise IdP configurations (XAA)");
1099
+ idp
1100
+ .command("add")
1101
+ .description("Register a trusted enterprise IdP")
1102
+ .requiredOption("--name <name>", "Display name (e.g., 'Harper Corporate')")
1103
+ .requiredOption("--issuer <url>", "IdP issuer URL (e.g., https://accounts.google.com)")
1104
+ .requiredOption("--jwks-uri <url>", "JWKS endpoint URL")
1105
+ .requiredOption("--client-id <id>", "Flair's client_id at this IdP")
1106
+ .option("--required-domain <domain>", "Reject tokens without this domain (hd/tid claim)")
1107
+ .option("--no-jit-provision", "Disable auto-creation of principals for new IdP users")
1108
+ .option("--default-trust <tier>", "Trust tier for JIT principals", "unverified")
1109
+ .option("--admin-pass <pass>", "Admin password")
1110
+ .option("--ops-port <port>", "Harper operations API port")
1111
+ .action(async (opts) => {
1112
+ const opsPort = resolveOpsPort(opts);
1113
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1114
+ if (!adminPass) {
1115
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1116
+ process.exit(1);
1117
+ }
1118
+ const id = `idp_${randomUUID().slice(0, 8)}`;
1119
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1120
+ const now = new Date().toISOString();
1121
+ const record = {
1122
+ id,
1123
+ name: opts.name,
1124
+ issuer: opts.issuer,
1125
+ jwksUri: opts.jwksUri,
1126
+ clientId: opts.clientId,
1127
+ requiredDomain: opts.requiredDomain ?? null,
1128
+ jitProvision: opts.jitProvision !== false,
1129
+ defaultTrustTier: opts.defaultTrust ?? "unverified",
1130
+ enabled: true,
1131
+ createdAt: now,
1132
+ updatedAt: now,
1133
+ };
1134
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1135
+ method: "POST",
1136
+ headers: { "Content-Type": "application/json", Authorization: auth },
1137
+ body: JSON.stringify({ operation: "upsert", database: "flair", table: "IdpConfig", records: [record] }),
1138
+ });
1139
+ if (!res.ok) {
1140
+ const text = await res.text().catch(() => "");
1141
+ console.error(`Error: ${res.status} ${text}`);
1142
+ process.exit(1);
1143
+ }
1144
+ console.log(`✅ IdP '${opts.name}' registered (id: ${id})`);
1145
+ console.log(` Issuer: ${opts.issuer}`);
1146
+ console.log(` JWKS: ${opts.jwksUri}`);
1147
+ console.log(` Client: ${opts.clientId}`);
1148
+ if (opts.requiredDomain)
1149
+ console.log(` Domain: ${opts.requiredDomain}`);
1150
+ console.log(` JIT: ${opts.jitProvision !== false}`);
1151
+ });
1152
+ idp
1153
+ .command("list")
1154
+ .description("List configured IdPs")
1155
+ .option("--admin-pass <pass>", "Admin password")
1156
+ .option("--ops-port <port>", "Harper operations API port")
1157
+ .action(async (opts) => {
1158
+ const opsPort = resolveOpsPort(opts);
1159
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1160
+ if (!adminPass) {
1161
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1162
+ process.exit(1);
1163
+ }
1164
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1165
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1166
+ method: "POST",
1167
+ headers: { "Content-Type": "application/json", Authorization: auth },
1168
+ body: JSON.stringify({
1169
+ operation: "sql",
1170
+ sql: "SELECT id, name, issuer, requiredDomain, jitProvision, enabled, createdAt FROM flair.IdpConfig ORDER BY createdAt",
1171
+ }),
1172
+ });
1173
+ if (!res.ok) {
1174
+ const text = await res.text().catch(() => "");
1175
+ console.error(`Error: ${res.status} ${text}`);
1176
+ process.exit(1);
1177
+ }
1178
+ const records = await res.json();
1179
+ if (records.length === 0) {
1180
+ console.log("No IdPs configured.");
1181
+ return;
1182
+ }
1183
+ for (const r of records) {
1184
+ const status = r.enabled ? "enabled" : "disabled";
1185
+ console.log(`${r.name} (${r.id}) — ${status}`);
1186
+ console.log(` Issuer: ${r.issuer}`);
1187
+ if (r.requiredDomain)
1188
+ console.log(` Domain: ${r.requiredDomain}`);
1189
+ console.log(` JIT: ${r.jitProvision ?? true}`);
1190
+ console.log();
1191
+ }
1192
+ });
1193
+ idp
1194
+ .command("remove <id>")
1195
+ .description("Remove an IdP configuration")
1196
+ .option("--admin-pass <pass>", "Admin password")
1197
+ .option("--ops-port <port>", "Harper operations API port")
1198
+ .action(async (id, opts) => {
1199
+ const opsPort = resolveOpsPort(opts);
1200
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1201
+ if (!adminPass) {
1202
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1203
+ process.exit(1);
1204
+ }
1205
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1206
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1207
+ method: "POST",
1208
+ headers: { "Content-Type": "application/json", Authorization: auth },
1209
+ body: JSON.stringify({ operation: "delete", database: "flair", table: "IdpConfig", hash_values: [id] }),
1210
+ });
1211
+ if (!res.ok) {
1212
+ const text = await res.text().catch(() => "");
1213
+ console.error(`Error: ${res.status} ${text}`);
1214
+ process.exit(1);
1215
+ }
1216
+ console.log(`✅ IdP '${id}' removed`);
1217
+ });
1218
+ idp
1219
+ .command("test <id>")
1220
+ .description("Test IdP connectivity (fetches JWKS)")
1221
+ .option("--admin-pass <pass>", "Admin password")
1222
+ .option("--ops-port <port>", "Harper operations API port")
1223
+ .action(async (id, opts) => {
1224
+ const opsPort = resolveOpsPort(opts);
1225
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1226
+ if (!adminPass) {
1227
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
1228
+ process.exit(1);
1229
+ }
1230
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1231
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1232
+ method: "POST",
1233
+ headers: { "Content-Type": "application/json", Authorization: auth },
1234
+ body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.IdpConfig WHERE id = '${id}'` }),
1235
+ });
1236
+ const records = await res.json();
1237
+ if (records.length === 0) {
1238
+ console.error(`IdP '${id}' not found`);
1239
+ process.exit(1);
1240
+ }
1241
+ const cfg = records[0];
1242
+ console.log(`Testing IdP: ${cfg.name} (${cfg.issuer})`);
1243
+ console.log(` JWKS endpoint: ${cfg.jwksUri}`);
1244
+ try {
1245
+ const jwksRes = await fetch(cfg.jwksUri, { signal: AbortSignal.timeout(10_000) });
1246
+ if (!jwksRes.ok) {
1247
+ console.error(` ❌ JWKS fetch failed: HTTP ${jwksRes.status}`);
1248
+ process.exit(1);
1249
+ }
1250
+ const jwks = await jwksRes.json();
1251
+ const keyCount = jwks.keys?.length ?? 0;
1252
+ console.log(` ✅ JWKS reachable — ${keyCount} key(s) found`);
1253
+ }
1254
+ catch (err) {
1255
+ console.error(` ❌ JWKS fetch error: ${err.message}`);
1256
+ process.exit(1);
1257
+ }
1258
+ });
819
1259
  // ─── flair grant / revoke ─────────────────────────────────────────────────────
820
1260
  program
821
1261
  .command("grant <from-agent> <to-agent>")
@@ -907,61 +1347,367 @@ program
907
1347
  console.log(`✅ Grant revoked: '${toAgent}' can no longer read '${fromAgent}'s memories`);
908
1348
  console.log(` Removed grant ID: ${grantId}`);
909
1349
  });
1350
+ // ─── Federation signing helpers ──────────────────────────────────────────────
1351
+ /**
1352
+ * Load the Ed25519 secret key for the local federation instance.
1353
+ * Tries keystore first, then falls back to DB-stored seed (migration path).
1354
+ */
1355
+ async function loadInstanceSecretKey(instanceId, opts) {
1356
+ // Try keystore first
1357
+ const seed = keystore.getPrivateKeySeed(instanceId);
1358
+ if (seed) {
1359
+ return nacl.sign.keyPair.fromSeed(seed).secretKey;
1360
+ }
1361
+ // Fallback: check DB for legacy _keySeed
1362
+ const opsPort = resolveOpsPort(opts);
1363
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1364
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1365
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1366
+ method: "POST",
1367
+ headers: { "Content-Type": "application/json", Authorization: auth },
1368
+ body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.Instance WHERE id = '${instanceId}'` }),
1369
+ });
1370
+ if (res.ok) {
1371
+ const rows = await res.json();
1372
+ if (rows[0]?._keySeed) {
1373
+ const seedFromDb = Buffer.from(rows[0]._keySeed, "base64url");
1374
+ // Migrate to keystore
1375
+ keystore.setPrivateKeySeed(instanceId, new Uint8Array(seedFromDb));
1376
+ return nacl.sign.keyPair.fromSeed(new Uint8Array(seedFromDb)).secretKey;
1377
+ }
1378
+ }
1379
+ throw new Error(`No private key found for instance ${instanceId}. Re-run 'flair federation status' to regenerate.`);
1380
+ }
1381
+ /**
1382
+ * Sign a request body and return a new body with the signature field added.
1383
+ */
1384
+ function signRequestBody(body, secretKey) {
1385
+ const sig = signBody(body, secretKey);
1386
+ return { ...body, signature: sig };
1387
+ }
1388
+ // ─── flair federation ────────────────────────────────────────────────────────
1389
+ const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
1390
+ federation
1391
+ .command("status")
1392
+ .description("Show federation status and peer connections")
1393
+ .option("--port <port>", "Harper HTTP port")
1394
+ .action(async (opts) => {
1395
+ try {
1396
+ const instance = await api("GET", "/FederationInstance");
1397
+ console.log(`Instance: ${instance.id} (${instance.role})`);
1398
+ console.log(`Public key: ${instance.publicKey}`);
1399
+ console.log(`Status: ${instance.status}`);
1400
+ console.log();
1401
+ const { peers } = await api("GET", "/FederationPeers");
1402
+ if (peers.length === 0) {
1403
+ console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
1404
+ }
1405
+ else {
1406
+ console.log(`${"Peer".padEnd(20)} ${"Role".padEnd(8)} ${"Status".padEnd(14)} ${"Last Sync".padEnd(22)} Relay`);
1407
+ console.log("─".repeat(80));
1408
+ for (const p of peers) {
1409
+ const lastSync = p.lastSyncAt?.slice(0, 19) ?? "never";
1410
+ console.log(`${p.id.padEnd(20)} ${(p.role ?? "—").padEnd(8)} ${(p.status ?? "—").padEnd(14)} ${lastSync.padEnd(22)} ${p.relayOnly ? "yes" : "no"}`);
1411
+ }
1412
+ }
1413
+ }
1414
+ catch (err) {
1415
+ console.error(`Error: ${err.message}`);
1416
+ process.exit(1);
1417
+ }
1418
+ });
1419
+ federation
1420
+ .command("pair <hub-url>")
1421
+ .description("Pair this spoke with a hub instance")
1422
+ .option("--port <port>", "Harper HTTP port")
1423
+ .option("--admin-pass <pass>", "Admin password")
1424
+ .option("--ops-port <port>", "Harper operations API port")
1425
+ .option("--token <token>", "One-time pairing token from hub admin")
1426
+ .action(async (hubUrl, opts) => {
1427
+ try {
1428
+ const instance = await api("GET", "/FederationInstance");
1429
+ console.log(`Local instance: ${instance.id} (${instance.role})`);
1430
+ if (!opts.token) {
1431
+ console.error("Error: --token is required. Ask the hub admin to run 'flair federation token' and provide the token.");
1432
+ process.exit(1);
1433
+ }
1434
+ // Load secret key and sign the pairing request
1435
+ const secretKey = await loadInstanceSecretKey(instance.id, opts);
1436
+ const pairBody = {
1437
+ instanceId: instance.id,
1438
+ publicKey: instance.publicKey,
1439
+ role: "spoke",
1440
+ pairingToken: opts.token,
1441
+ };
1442
+ const signedBody = signRequestBody(pairBody, secretKey);
1443
+ const res = await fetch(`${hubUrl}/FederationPair`, {
1444
+ method: "POST",
1445
+ headers: { "Content-Type": "application/json" },
1446
+ body: JSON.stringify(signedBody),
1447
+ });
1448
+ if (!res.ok) {
1449
+ const text = await res.text().catch(() => "");
1450
+ console.error(`Pairing failed: ${res.status} ${text}`);
1451
+ process.exit(1);
1452
+ }
1453
+ const result = await res.json();
1454
+ console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
1455
+ // Record the hub as our peer locally
1456
+ const opsPort = resolveOpsPort(opts);
1457
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1458
+ if (adminPass) {
1459
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1460
+ await fetch(`http://127.0.0.1:${opsPort}/`, {
1461
+ method: "POST",
1462
+ headers: { "Content-Type": "application/json", Authorization: auth },
1463
+ body: JSON.stringify({
1464
+ operation: "upsert", database: "flair", table: "Peer",
1465
+ records: [{
1466
+ id: result.instance?.id ?? "hub",
1467
+ publicKey: result.instance?.publicKey ?? "",
1468
+ role: "hub", endpoint: hubUrl, status: "paired",
1469
+ pairedAt: new Date().toISOString(),
1470
+ createdAt: new Date().toISOString(),
1471
+ updatedAt: new Date().toISOString(),
1472
+ }],
1473
+ }),
1474
+ });
1475
+ }
1476
+ }
1477
+ catch (err) {
1478
+ console.error(`Error: ${err.message}`);
1479
+ process.exit(1);
1480
+ }
1481
+ });
1482
+ federation
1483
+ .command("token")
1484
+ .description("Generate a one-time pairing token (run on the hub)")
1485
+ .option("--port <port>", "Harper HTTP port")
1486
+ .option("--admin-pass <pass>", "Admin password")
1487
+ .option("--ops-port <port>", "Harper operations API port")
1488
+ .option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
1489
+ .action(async (opts) => {
1490
+ try {
1491
+ const { randomBytes } = await import("node:crypto");
1492
+ const token = randomBytes(24).toString("base64url");
1493
+ const ttlMinutes = parseInt(opts.ttl, 10) || 60;
1494
+ const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
1495
+ const opsPort = resolveOpsPort(opts);
1496
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1497
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1498
+ await fetch(`http://127.0.0.1:${opsPort}/`, {
1499
+ method: "POST",
1500
+ headers: { "Content-Type": "application/json", Authorization: auth },
1501
+ body: JSON.stringify({
1502
+ operation: "upsert", database: "flair", table: "PairingToken",
1503
+ records: [{
1504
+ id: token,
1505
+ createdAt: new Date().toISOString(),
1506
+ expiresAt,
1507
+ }],
1508
+ }),
1509
+ });
1510
+ console.log(`Pairing token (expires in ${ttlMinutes}m):`);
1511
+ console.log(` ${token}`);
1512
+ console.log(`\nGive this to the spoke admin to run:`);
1513
+ console.log(` flair federation pair <this-hub-url> --token ${token}`);
1514
+ }
1515
+ catch (err) {
1516
+ console.error(`Error: ${err.message}`);
1517
+ process.exit(1);
1518
+ }
1519
+ });
1520
+ federation
1521
+ .command("sync")
1522
+ .description("Push local changes to the hub")
1523
+ .option("--port <port>", "Harper HTTP port")
1524
+ .option("--admin-pass <pass>", "Admin password")
1525
+ .option("--ops-port <port>", "Harper operations API port")
1526
+ .action(async (opts) => {
1527
+ try {
1528
+ const { peers } = await api("GET", "/FederationPeers");
1529
+ const hub = peers.find((p) => p.role === "hub" && p.status !== "revoked");
1530
+ if (!hub) {
1531
+ console.error("No hub peer configured. Use 'flair federation pair' first.");
1532
+ process.exit(1);
1533
+ }
1534
+ console.log(`Syncing to hub: ${hub.id}...`);
1535
+ const since = hub.lastSyncAt ?? new Date(0).toISOString();
1536
+ const opsPort = resolveOpsPort(opts);
1537
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
1538
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1539
+ const tables = ["Memory", "Soul", "Agent", "Relationship"];
1540
+ const records = [];
1541
+ const instance = await api("GET", "/FederationInstance");
1542
+ for (const table of tables) {
1543
+ try {
1544
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1545
+ method: "POST",
1546
+ headers: { "Content-Type": "application/json", Authorization: auth },
1547
+ body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.${table} WHERE updatedAt > '${since}'` }),
1548
+ });
1549
+ if (res.ok) {
1550
+ for (const row of await res.json()) {
1551
+ records.push({ table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id });
1552
+ }
1553
+ }
1554
+ }
1555
+ catch { }
1556
+ }
1557
+ if (records.length === 0) {
1558
+ console.log("No changes since last sync.");
1559
+ return;
1560
+ }
1561
+ // Sign the sync request with our instance key
1562
+ const secretKey = await loadInstanceSecretKey(instance.id, opts);
1563
+ const syncBody = { instanceId: instance.id, records, lamportClock: Date.now() };
1564
+ const signedSyncBody = signRequestBody(syncBody, secretKey);
1565
+ const syncRes = await fetch(`${hub.endpoint ?? hub.id}/FederationSync`, {
1566
+ method: "POST",
1567
+ headers: { "Content-Type": "application/json" },
1568
+ body: JSON.stringify(signedSyncBody),
1569
+ });
1570
+ if (!syncRes.ok) {
1571
+ console.error(`Sync failed: ${syncRes.status} ${await syncRes.text().catch(() => "")}`);
1572
+ process.exit(1);
1573
+ }
1574
+ const result = await syncRes.json();
1575
+ console.log(`✅ Synced ${result.merged} records (${result.skipped} skipped) in ${result.durationMs}ms`);
1576
+ }
1577
+ catch (err) {
1578
+ console.error(`Error: ${err.message}`);
1579
+ process.exit(1);
1580
+ }
1581
+ });
910
1582
  // ─── flair status ─────────────────────────────────────────────────────────────
911
1583
  program
912
1584
  .command("status")
913
- .description("Check Flair (Harper) instance health and agent count")
1585
+ .description("Show Flair instance status, memory stats, and agent info")
914
1586
  .option("--port <port>", "Harper HTTP port")
915
1587
  .option("--url <url>", "Flair base URL (overrides --port)")
1588
+ .option("--json", "Output as JSON")
1589
+ .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
916
1590
  .action(async (opts) => {
917
1591
  const port = resolveHttpPort(opts);
918
1592
  const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
919
1593
  let healthy = false;
920
- let agentCount = null;
921
- let version = null;
1594
+ let healthData = null;
1595
+ // 1. Basic health check (unauthenticated — just { ok: true })
922
1596
  try {
923
- const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(3000) });
924
- healthy = res.status > 0;
925
- if (res.headers.get("content-type")?.includes("application/json")) {
926
- const body = await res.json().catch(() => null);
927
- if (body?.version)
928
- version = body.version;
929
- }
1597
+ const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1598
+ healthy = res.ok;
930
1599
  }
931
1600
  catch { /* unreachable */ }
1601
+ // 2. Try authenticated /HealthDetail for rich stats
932
1602
  if (healthy) {
933
- try {
934
- const agents = await fetch(`${baseUrl}/Agent`, { signal: AbortSignal.timeout(3000) });
935
- if (agents.ok) {
936
- const list = await agents.json().catch(() => null);
937
- if (Array.isArray(list))
938
- agentCount = list.length;
1603
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
1604
+ if (agentId) {
1605
+ const keyPath = resolveKeyPath(agentId);
1606
+ if (keyPath) {
1607
+ try {
1608
+ const authHeader = buildEd25519Auth(agentId, "GET", "/HealthDetail", keyPath);
1609
+ const res = await fetch(`${baseUrl}/HealthDetail`, {
1610
+ headers: { Authorization: authHeader },
1611
+ signal: AbortSignal.timeout(5000),
1612
+ });
1613
+ if (res.ok) {
1614
+ healthData = await res.json().catch(() => null);
1615
+ }
1616
+ }
1617
+ catch { /* fall through to basic output */ }
1618
+ }
1619
+ }
1620
+ // Fallback: try admin basic auth if available
1621
+ if (!healthData) {
1622
+ const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
1623
+ if (adminPass) {
1624
+ try {
1625
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1626
+ const res = await fetch(`${baseUrl}/HealthDetail`, {
1627
+ headers: { Authorization: auth },
1628
+ signal: AbortSignal.timeout(5000),
1629
+ });
1630
+ if (res.ok) {
1631
+ healthData = await res.json().catch(() => null);
1632
+ }
1633
+ }
1634
+ catch { /* fall through to basic output */ }
939
1635
  }
940
1636
  }
941
- catch { /* best effort */ }
942
- }
943
- const status = healthy ? "🟢 running" : "🔴 unreachable";
944
- console.log(`Flair status: ${status}`);
945
- console.log(` URL: ${baseUrl}`);
946
- console.log(` Flair: v${__pkgVersion}`);
947
- if (version)
948
- console.log(` Harper: ${version}`);
949
- if (agentCount !== null)
950
- console.log(` Agents: ${agentCount}`);
951
- if (!healthy)
1637
+ }
1638
+ if (opts.json) {
1639
+ console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
1640
+ if (!healthy)
1641
+ process.exit(1);
1642
+ return;
1643
+ }
1644
+ if (!healthy) {
1645
+ console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
1646
+ console.log(` URL: ${baseUrl}`);
1647
+ console.log(`\n Run: flair start or flair doctor`);
952
1648
  process.exit(1);
1649
+ }
1650
+ // Format uptime
1651
+ const uptimeSec = healthData?.uptimeSeconds;
1652
+ let uptimeStr = "";
1653
+ if (uptimeSec != null) {
1654
+ const d = Math.floor(uptimeSec / 86400);
1655
+ const h = Math.floor((uptimeSec % 86400) / 3600);
1656
+ const m = Math.floor((uptimeSec % 3600) / 60);
1657
+ uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
1658
+ }
1659
+ // Format last write as relative time
1660
+ let lastWriteStr = "";
1661
+ if (healthData?.lastWrite) {
1662
+ const ago = Date.now() - new Date(healthData.lastWrite).getTime();
1663
+ const mins = Math.floor(ago / 60000);
1664
+ const hrs = Math.floor(ago / 3600000);
1665
+ const days = Math.floor(ago / 86400000);
1666
+ lastWriteStr = days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
1667
+ }
1668
+ const pid = healthData?.pid ?? "";
1669
+ const agents = healthData?.agents;
1670
+ const memories = healthData?.memories;
1671
+ console.log(`Flair v${__pkgVersion} — 🟢 running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
1672
+ console.log(` URL: ${baseUrl}`);
1673
+ if (memories) {
1674
+ const embStr = memories.withEmbeddings > 0
1675
+ ? `${memories.withEmbeddings} with embeddings`
1676
+ : "";
1677
+ const hashStr = memories.hashFallback > 0
1678
+ ? `${memories.hashFallback} hash-fallback`
1679
+ : "";
1680
+ const detail = [embStr, hashStr].filter(Boolean).join(", ");
1681
+ console.log(` Memories: ${memories.total}${detail ? ` (${detail})` : ""}`);
1682
+ }
1683
+ if (agents) {
1684
+ const nameStr = agents.names?.length > 0 ? ` (${agents.names.join(", ")})` : "";
1685
+ console.log(` Agents: ${agents.count}${nameStr}`);
1686
+ }
1687
+ if (healthData?.soulEntries != null) {
1688
+ console.log(` Soul: ${healthData.soulEntries} entries`);
1689
+ }
1690
+ if (lastWriteStr) {
1691
+ console.log(` Last write: ${lastWriteStr}`);
1692
+ }
1693
+ console.log(` Health: ✅ all checks passing`);
953
1694
  });
954
1695
  // ─── flair upgrade ────────────────────────────────────────────────────────────
955
1696
  program
956
1697
  .command("upgrade")
957
1698
  .description("Upgrade Flair and related packages to latest versions")
958
- .action(async () => {
1699
+ .option("--check", "Only check for updates, don't install")
1700
+ .option("--restart", "Restart Flair after upgrade")
1701
+ .action(async (opts) => {
1702
+ const { execSync } = await import("node:child_process");
1703
+ const checkOnly = opts.check ?? false;
959
1704
  console.log("Checking for updates...\n");
960
1705
  const packages = [
961
1706
  "@tpsdev-ai/flair",
962
1707
  "@tpsdev-ai/flair-client",
963
1708
  "@tpsdev-ai/flair-mcp",
964
1709
  ];
1710
+ const upgrades = [];
965
1711
  for (const pkg of packages) {
966
1712
  try {
967
1713
  const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, { signal: AbortSignal.timeout(5000) });
@@ -969,22 +1715,67 @@ program
969
1715
  continue;
970
1716
  const data = await res.json();
971
1717
  const latest = data.version ?? "unknown";
972
- // Check installed version
973
1718
  let installed = "not installed";
974
1719
  try {
975
- const { execSync } = await import("node:child_process");
976
- installed = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null | grep ${pkg} || echo "not installed"`, { encoding: "utf-8" }).trim();
977
- const match = installed.match(/@(\d+\.\d+\.\d+)/);
1720
+ const out = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null || true`, { encoding: "utf-8" }).trim();
1721
+ const match = out.match(/@(\d+\.\d+[\d.a-z-]*)/);
978
1722
  installed = match ? match[1] : "not installed";
979
1723
  }
980
1724
  catch { /* best effort */ }
981
1725
  const upToDate = installed === latest;
982
1726
  const icon = upToDate ? "✅" : "⬆️";
983
1727
  console.log(` ${icon} ${pkg}: ${installed} → ${latest}${upToDate ? " (current)" : ""}`);
1728
+ if (!upToDate && installed !== "not installed") {
1729
+ upgrades.push({ pkg, installed, latest });
1730
+ }
984
1731
  }
985
1732
  catch { /* skip unavailable packages */ }
986
1733
  }
987
- console.log("\nTo upgrade: npm install -g @tpsdev-ai/flair@latest");
1734
+ if (upgrades.length === 0) {
1735
+ console.log("\n✅ Everything is up to date.");
1736
+ return;
1737
+ }
1738
+ if (checkOnly) {
1739
+ console.log(`\n${upgrades.length} update${upgrades.length > 1 ? "s" : ""} available. Run: flair upgrade`);
1740
+ return;
1741
+ }
1742
+ // Perform upgrade
1743
+ console.log(`\nUpgrading ${upgrades.length} package${upgrades.length > 1 ? "s" : ""}...\n`);
1744
+ for (const { pkg, latest } of upgrades) {
1745
+ try {
1746
+ console.log(` Installing ${pkg}@${latest}...`);
1747
+ execSync(`npm install -g ${pkg}@${latest}`, { stdio: "pipe" });
1748
+ console.log(` ✅ ${pkg}@${latest} installed`);
1749
+ }
1750
+ catch (err) {
1751
+ console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
1752
+ }
1753
+ }
1754
+ if (opts.restart) {
1755
+ console.log("\nRestarting Flair...");
1756
+ try {
1757
+ const port = resolveHttpPort({});
1758
+ const label = "ai.tpsdev.flair";
1759
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
1760
+ if (process.platform === "darwin" && existsSync(plistPath)) {
1761
+ try {
1762
+ execSync(`launchctl stop ${label}`, { stdio: "pipe" });
1763
+ }
1764
+ catch { }
1765
+ await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
1766
+ console.log("✅ Flair restarted with new version");
1767
+ }
1768
+ else {
1769
+ console.log("Run: flair restart");
1770
+ }
1771
+ }
1772
+ catch {
1773
+ console.log("Run: flair restart");
1774
+ }
1775
+ }
1776
+ else {
1777
+ console.log("\nRun: flair restart to use the new version");
1778
+ }
988
1779
  });
989
1780
  // ─── flair stop ───────────────────────────────────────────────────────────────
990
1781
  program
@@ -1029,6 +1820,83 @@ program
1029
1820
  console.log("Flair is not running (nothing found on port " + port + ").");
1030
1821
  }
1031
1822
  });
1823
+ // ─── flair start ──────────────────────────────────────────────────────────────
1824
+ program
1825
+ .command("start")
1826
+ .description("Start Flair (Harper) — requires a prior 'flair init'")
1827
+ .option("--port <port>", "Harper HTTP port")
1828
+ .action(async (opts) => {
1829
+ const port = resolveHttpPort(opts);
1830
+ // Check if already running
1831
+ try {
1832
+ const res = await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
1833
+ if (res.status > 0) {
1834
+ console.log(`Flair is already running on port ${port}.`);
1835
+ return;
1836
+ }
1837
+ }
1838
+ catch { /* not running — good */ }
1839
+ const dataDir = defaultDataDir();
1840
+ if (!existsSync(dataDir)) {
1841
+ console.error("❌ No Flair data directory found. Run 'flair init' first.");
1842
+ process.exit(1);
1843
+ }
1844
+ const platform = process.platform;
1845
+ if (platform === "darwin") {
1846
+ const label = "ai.tpsdev.flair";
1847
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
1848
+ if (existsSync(plistPath)) {
1849
+ try {
1850
+ const { execSync } = await import("node:child_process");
1851
+ try {
1852
+ execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
1853
+ }
1854
+ catch { }
1855
+ execSync(`launchctl start ${label}`, { stdio: "pipe" });
1856
+ await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
1857
+ console.log("✅ Flair started (launchd)");
1858
+ return;
1859
+ }
1860
+ catch (err) {
1861
+ console.error(`launchd start failed, falling back to direct start: ${err.message}`);
1862
+ }
1863
+ }
1864
+ }
1865
+ // Direct start (Linux, or macOS fallback when no launchd plist)
1866
+ const bin = harperBin();
1867
+ if (!bin) {
1868
+ console.error("❌ Harper binary not found. Run 'flair init' first.");
1869
+ process.exit(1);
1870
+ }
1871
+ const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
1872
+ const opsPort = resolveOpsPort(opts);
1873
+ const env = {
1874
+ ...process.env,
1875
+ ROOTPATH: dataDir,
1876
+ DEFAULTS_MODE: "dev",
1877
+ HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
1878
+ HTTP_PORT: String(port),
1879
+ OPERATIONSAPI_NETWORK_PORT: String(opsPort),
1880
+ LOCAL_STUDIO: "false",
1881
+ };
1882
+ // Only set HDB_ADMIN_PASSWORD if we have a real value — empty string
1883
+ // would strip Harper's auth on an existing install
1884
+ if (adminPass) {
1885
+ env.HDB_ADMIN_PASSWORD = adminPass;
1886
+ }
1887
+ const proc = spawn(process.execPath, [bin, "run", "."], {
1888
+ cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
1889
+ });
1890
+ proc.unref();
1891
+ try {
1892
+ await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
1893
+ console.log(`✅ Flair started on port ${port}`);
1894
+ }
1895
+ catch {
1896
+ console.error("❌ Flair failed to start within timeout. Check logs in " + join(dataDir, "harper.log"));
1897
+ process.exit(1);
1898
+ }
1899
+ });
1032
1900
  // ─── flair restart ────────────────────────────────────────────────────────────
1033
1901
  program
1034
1902
  .command("restart")
@@ -1043,22 +1911,27 @@ program
1043
1911
  if (existsSync(plistPath)) {
1044
1912
  try {
1045
1913
  const { execSync } = await import("node:child_process");
1046
- const uid = process.getuid?.() ?? 501;
1047
- execSync(`launchctl kickstart -k user/${uid}/${label}`, { stdio: "pipe" });
1048
- console.log(" Flair restarted (launchd kickstart)");
1914
+ // Ensure the service is loaded (init writes the plist but doesn't load it)
1915
+ try {
1916
+ execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
1917
+ }
1918
+ catch { }
1919
+ // Stop the service — KeepAlive=true in the plist auto-restarts it
1920
+ try {
1921
+ execSync(`launchctl stop ${label}`, { stdio: "pipe" });
1922
+ }
1923
+ catch { }
1924
+ await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
1925
+ console.log("✅ Flair restarted");
1049
1926
  return;
1050
1927
  }
1051
1928
  catch (err) {
1052
- console.error(`launchd restart failed: ${err.message}`);
1929
+ console.error(`launchd restart failed, falling back to port restart: ${err.message}`);
1053
1930
  }
1054
1931
  }
1055
- else {
1056
- console.error("❌ No launchd service found. Run 'flair init' first.");
1057
- process.exit(1);
1058
- }
1059
1932
  }
1060
- else {
1061
- // Linux: stop + start via init
1933
+ {
1934
+ // Port-based restart (Linux, or macOS fallback)
1062
1935
  console.log("Stopping...");
1063
1936
  try {
1064
1937
  const { execSync } = await import("node:child_process");
@@ -1269,80 +2142,71 @@ program
1269
2142
  // ─── flair test ───────────────────────────────────────────────────────────────
1270
2143
  program
1271
2144
  .command("test")
1272
- .description("Verify Flair is working: store, search, bootstrap, cleanup")
1273
- .requiredOption("--agent <id>", "Agent ID to test with")
2145
+ .description("Verify the full Flair stack: write, search, and delete a test memory")
2146
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
1274
2147
  .option("--port <port>", "Harper HTTP port")
1275
2148
  .action(async (opts) => {
1276
- const port = resolveHttpPort(opts);
1277
- const baseUrl = `http://127.0.0.1:${port}`;
1278
- const agentId = opts.agent;
1279
- const keysDir = defaultKeysDir();
1280
- const privPath = privKeyPath(agentId, keysDir);
1281
- if (!existsSync(privPath)) {
1282
- console.error(`❌ Key not found: ${privPath}`);
1283
- console.error(` Run: flair init --agent-id ${agentId}`);
2149
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
2150
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
2151
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
2152
+ if (!agentId && !process.env.FLAIR_ADMIN_PASS) {
2153
+ console.error(red("Error: set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS"));
1284
2154
  process.exit(1);
1285
2155
  }
1286
- const testId = `test-${agentId}-${Date.now()}`;
1287
- const testContent = `Flair test memory (${new Date().toISOString()})`;
2156
+ const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
2157
+ console.log(`\nFlair test (url: ${baseUrl})\n`);
1288
2158
  let passed = 0;
1289
2159
  let failed = 0;
2160
+ let memoryId = null;
1290
2161
  const check = async (name, fn) => {
1291
2162
  try {
1292
2163
  const ok = await fn();
1293
2164
  if (ok) {
1294
- console.log(` ${name}`);
2165
+ console.log(` ${green("PASS")} ${name}`);
1295
2166
  passed++;
1296
2167
  }
1297
2168
  else {
1298
- console.log(` ${name}`);
2169
+ console.log(` ${red("FAIL")} ${name}`);
1299
2170
  failed++;
1300
2171
  }
1301
2172
  }
1302
2173
  catch (e) {
1303
- console.log(` ${name}: ${e.message?.slice(0, 100)}`);
2174
+ console.log(` ${red("FAIL")} ${name}: ${e.message?.slice(0, 120)}`);
1304
2175
  failed++;
1305
2176
  }
1306
2177
  };
1307
- console.log(`\nFlair test (agent: ${agentId}, url: ${baseUrl})\n`);
1308
- // 1. Health
1309
- await check("Health check", async () => {
1310
- const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1311
- return res.status > 0;
1312
- });
1313
- // 2. Store
1314
- await check("Memory store", async () => {
1315
- const res = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${testId}`, {
1316
- id: testId, agentId, content: testContent, durability: "ephemeral",
1317
- createdAt: new Date().toISOString(), archived: false,
1318
- });
1319
- return res.ok;
1320
- });
1321
- // 3. Search
1322
- await check("Semantic search", async () => {
1323
- await new Promise(r => setTimeout(r, 2000)); // wait for indexing
1324
- const res = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
1325
- agentId, q: "flair test memory", limit: 5,
1326
- });
1327
- if (!res.ok)
1328
- return false;
1329
- const data = await res.json();
1330
- return (data.results?.length ?? 0) > 0;
2178
+ // 1. Write a test memory via POST /Memory
2179
+ await check("Write test memory (POST /Memory)", async () => {
2180
+ const body = {
2181
+ content: "flair test \u2014 this will be deleted",
2182
+ durability: "ephemeral",
2183
+ createdAt: new Date().toISOString(),
2184
+ };
2185
+ if (agentId)
2186
+ body.agentId = agentId;
2187
+ const result = await api("POST", "/Memory", body);
2188
+ // id may be returned directly or nested
2189
+ memoryId = result?.id ?? result?.[0]?.id ?? null;
2190
+ return !!memoryId || result?.ok === true;
1331
2191
  });
1332
- // 4. Bootstrap
1333
- await check("Bootstrap context", async () => {
1334
- const res = await authFetch(baseUrl, agentId, privPath, "POST", "/BootstrapMemories", {
1335
- agentId, maxTokens: 1000,
1336
- });
1337
- if (!res.ok)
1338
- return false;
1339
- const data = await res.json();
1340
- return (data.context?.length ?? 0) > 0;
2192
+ // 2. Search for the test memory via POST /SemanticSearch
2193
+ await check("Search for test memory (POST /SemanticSearch)", async () => {
2194
+ await new Promise(r => setTimeout(r, 1500)); // allow indexing
2195
+ const body = { q: "flair test", limit: 5 };
2196
+ if (agentId)
2197
+ body.agentId = agentId;
2198
+ const result = await api("POST", "/SemanticSearch", body);
2199
+ return (result?.results?.length ?? 0) > 0;
1341
2200
  });
1342
- // 5. Cleanup
1343
- await check("Memory delete", async () => {
1344
- const res = await authFetch(baseUrl, agentId, privPath, "DELETE", `/Memory/${testId}`);
1345
- return res.ok || res.status === 204;
2201
+ // 3. Delete the test memory via DELETE /Memory/<id>
2202
+ await check("Delete test memory (DELETE /Memory/<id>)", async () => {
2203
+ if (!memoryId) {
2204
+ // If write returned ok without an id, skip deletion cleanly
2205
+ console.log(` (skipped — no id returned from write step)`);
2206
+ return true;
2207
+ }
2208
+ await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
2209
+ return true;
1346
2210
  });
1347
2211
  console.log(`\n${passed} passed, ${failed} failed`);
1348
2212
  if (failed > 0)
@@ -2227,4 +3091,9 @@ program
2227
3091
  }
2228
3092
  }
2229
3093
  });
2230
- await program.parseAsync();
3094
+ // Run CLI only when this is the entry point (not when imported for testing)
3095
+ if (import.meta.main) {
3096
+ await program.parseAsync();
3097
+ }
3098
+ // ─── Exported for testing ─────────────────────────────────────────────────────
3099
+ export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, signRequestBody, b64, b64url, program, };