@tpsdev-ai/flair 0.4.16 → 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).
@@ -490,7 +520,25 @@ program
490
520
  console.log("\n🎭 Set up agent personality (press Enter to skip any):\n");
491
521
  const { createInterface } = await import("node:readline");
492
522
  const rl = createInterface({ input: process.stdin, output: process.stdout });
493
- 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
+ });
494
542
  const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
495
543
  const project = await ask(" What project is it working on?\n > ");
496
544
  const standards = await ask(" Any coding standards or preferences?\n > ");
@@ -520,6 +568,25 @@ program
520
568
  console.log("\n No soul entries — you can add them later with: flair soul set --agent " + agentId + " --key role --value \"...\"");
521
569
  }
522
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
+ }
523
590
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
524
591
  console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
525
592
  // Auto-wire MCP config into ~/.claude.json if Claude Code is installed
@@ -822,6 +889,373 @@ agent
822
889
  }
823
890
  console.log(`\n✅ Agent '${id}' removed successfully`);
824
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
+ });
825
1259
  // ─── flair grant / revoke ─────────────────────────────────────────────────────
826
1260
  program
827
1261
  .command("grant <from-agent> <to-agent>")
@@ -913,61 +1347,367 @@ program
913
1347
  console.log(`✅ Grant revoked: '${toAgent}' can no longer read '${fromAgent}'s memories`);
914
1348
  console.log(` Removed grant ID: ${grantId}`);
915
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
+ });
916
1582
  // ─── flair status ─────────────────────────────────────────────────────────────
917
1583
  program
918
1584
  .command("status")
919
- .description("Check Flair (Harper) instance health and agent count")
1585
+ .description("Show Flair instance status, memory stats, and agent info")
920
1586
  .option("--port <port>", "Harper HTTP port")
921
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)")
922
1590
  .action(async (opts) => {
923
1591
  const port = resolveHttpPort(opts);
924
1592
  const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
925
1593
  let healthy = false;
926
- let agentCount = null;
927
- let version = null;
1594
+ let healthData = null;
1595
+ // 1. Basic health check (unauthenticated — just { ok: true })
928
1596
  try {
929
- const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(3000) });
930
- healthy = res.status > 0;
931
- if (res.headers.get("content-type")?.includes("application/json")) {
932
- const body = await res.json().catch(() => null);
933
- if (body?.version)
934
- version = body.version;
935
- }
1597
+ const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1598
+ healthy = res.ok;
936
1599
  }
937
1600
  catch { /* unreachable */ }
1601
+ // 2. Try authenticated /HealthDetail for rich stats
938
1602
  if (healthy) {
939
- try {
940
- const agents = await fetch(`${baseUrl}/Agent`, { signal: AbortSignal.timeout(3000) });
941
- if (agents.ok) {
942
- const list = await agents.json().catch(() => null);
943
- if (Array.isArray(list))
944
- 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 */ }
945
1618
  }
946
1619
  }
947
- catch { /* best effort */ }
948
- }
949
- const status = healthy ? "🟢 running" : "🔴 unreachable";
950
- console.log(`Flair status: ${status}`);
951
- console.log(` URL: ${baseUrl}`);
952
- console.log(` Flair: v${__pkgVersion}`);
953
- if (version)
954
- console.log(` Harper: ${version}`);
955
- if (agentCount !== null)
956
- console.log(` Agents: ${agentCount}`);
957
- if (!healthy)
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 */ }
1635
+ }
1636
+ }
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`);
958
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`);
959
1694
  });
960
1695
  // ─── flair upgrade ────────────────────────────────────────────────────────────
961
1696
  program
962
1697
  .command("upgrade")
963
1698
  .description("Upgrade Flair and related packages to latest versions")
964
- .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;
965
1704
  console.log("Checking for updates...\n");
966
1705
  const packages = [
967
1706
  "@tpsdev-ai/flair",
968
1707
  "@tpsdev-ai/flair-client",
969
1708
  "@tpsdev-ai/flair-mcp",
970
1709
  ];
1710
+ const upgrades = [];
971
1711
  for (const pkg of packages) {
972
1712
  try {
973
1713
  const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, { signal: AbortSignal.timeout(5000) });
@@ -975,22 +1715,67 @@ program
975
1715
  continue;
976
1716
  const data = await res.json();
977
1717
  const latest = data.version ?? "unknown";
978
- // Check installed version
979
1718
  let installed = "not installed";
980
1719
  try {
981
- const { execSync } = await import("node:child_process");
982
- installed = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null | grep ${pkg} || echo "not installed"`, { encoding: "utf-8" }).trim();
983
- 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-]*)/);
984
1722
  installed = match ? match[1] : "not installed";
985
1723
  }
986
1724
  catch { /* best effort */ }
987
1725
  const upToDate = installed === latest;
988
1726
  const icon = upToDate ? "✅" : "⬆️";
989
1727
  console.log(` ${icon} ${pkg}: ${installed} → ${latest}${upToDate ? " (current)" : ""}`);
1728
+ if (!upToDate && installed !== "not installed") {
1729
+ upgrades.push({ pkg, installed, latest });
1730
+ }
990
1731
  }
991
1732
  catch { /* skip unavailable packages */ }
992
1733
  }
993
- 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
+ }
994
1779
  });
995
1780
  // ─── flair stop ───────────────────────────────────────────────────────────────
996
1781
  program
@@ -1035,6 +1820,83 @@ program
1035
1820
  console.log("Flair is not running (nothing found on port " + port + ").");
1036
1821
  }
1037
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
+ });
1038
1900
  // ─── flair restart ────────────────────────────────────────────────────────────
1039
1901
  program
1040
1902
  .command("restart")
@@ -1280,80 +2142,71 @@ program
1280
2142
  // ─── flair test ───────────────────────────────────────────────────────────────
1281
2143
  program
1282
2144
  .command("test")
1283
- .description("Verify Flair is working: store, search, bootstrap, cleanup")
1284
- .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)")
1285
2147
  .option("--port <port>", "Harper HTTP port")
1286
2148
  .action(async (opts) => {
1287
- const port = resolveHttpPort(opts);
1288
- const baseUrl = `http://127.0.0.1:${port}`;
1289
- const agentId = opts.agent;
1290
- const keysDir = defaultKeysDir();
1291
- const privPath = privKeyPath(agentId, keysDir);
1292
- if (!existsSync(privPath)) {
1293
- console.error(`❌ Key not found: ${privPath}`);
1294
- 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"));
1295
2154
  process.exit(1);
1296
2155
  }
1297
- const testId = `test-${agentId}-${Date.now()}`;
1298
- 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`);
1299
2158
  let passed = 0;
1300
2159
  let failed = 0;
2160
+ let memoryId = null;
1301
2161
  const check = async (name, fn) => {
1302
2162
  try {
1303
2163
  const ok = await fn();
1304
2164
  if (ok) {
1305
- console.log(` ${name}`);
2165
+ console.log(` ${green("PASS")} ${name}`);
1306
2166
  passed++;
1307
2167
  }
1308
2168
  else {
1309
- console.log(` ${name}`);
2169
+ console.log(` ${red("FAIL")} ${name}`);
1310
2170
  failed++;
1311
2171
  }
1312
2172
  }
1313
2173
  catch (e) {
1314
- console.log(` ${name}: ${e.message?.slice(0, 100)}`);
2174
+ console.log(` ${red("FAIL")} ${name}: ${e.message?.slice(0, 120)}`);
1315
2175
  failed++;
1316
2176
  }
1317
2177
  };
1318
- console.log(`\nFlair test (agent: ${agentId}, url: ${baseUrl})\n`);
1319
- // 1. Health
1320
- await check("Health check", async () => {
1321
- const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1322
- return res.status > 0;
1323
- });
1324
- // 2. Store
1325
- await check("Memory store", async () => {
1326
- const res = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${testId}`, {
1327
- id: testId, agentId, content: testContent, durability: "ephemeral",
1328
- createdAt: new Date().toISOString(), archived: false,
1329
- });
1330
- return res.ok;
1331
- });
1332
- // 3. Search
1333
- await check("Semantic search", async () => {
1334
- await new Promise(r => setTimeout(r, 2000)); // wait for indexing
1335
- const res = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
1336
- agentId, q: "flair test memory", limit: 5,
1337
- });
1338
- if (!res.ok)
1339
- return false;
1340
- const data = await res.json();
1341
- 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;
1342
2191
  });
1343
- // 4. Bootstrap
1344
- await check("Bootstrap context", async () => {
1345
- const res = await authFetch(baseUrl, agentId, privPath, "POST", "/BootstrapMemories", {
1346
- agentId, maxTokens: 1000,
1347
- });
1348
- if (!res.ok)
1349
- return false;
1350
- const data = await res.json();
1351
- 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;
1352
2200
  });
1353
- // 5. Cleanup
1354
- await check("Memory delete", async () => {
1355
- const res = await authFetch(baseUrl, agentId, privPath, "DELETE", `/Memory/${testId}`);
1356
- 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;
1357
2210
  });
1358
2211
  console.log(`\n${passed} passed, ${failed} failed`);
1359
2212
  if (failed > 0)
@@ -2238,4 +3091,9 @@ program
2238
3091
  }
2239
3092
  }
2240
3093
  });
2241
- 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, };