@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/README.md +49 -0
- package/dist/cli.js +948 -90
- package/dist/keystore.js +116 -0
- package/dist/resources/AdminConnectors.js +54 -0
- package/dist/resources/AdminDashboard.js +68 -0
- package/dist/resources/AdminIdp.js +57 -0
- package/dist/resources/AdminInstance.js +64 -0
- package/dist/resources/AdminMemory.js +92 -0
- package/dist/resources/AdminPrincipals.js +71 -0
- package/dist/resources/Agent.js +49 -2
- package/dist/resources/Credential.js +105 -0
- package/dist/resources/Federation.js +315 -0
- package/dist/resources/Memory.js +11 -0
- package/dist/resources/MemoryBootstrap.js +91 -4
- package/dist/resources/OAuth.js +381 -0
- package/dist/resources/Relationship.js +96 -0
- package/dist/resources/SemanticSearch.js +20 -4
- package/dist/resources/XAA.js +264 -0
- package/dist/resources/admin-layout.js +94 -0
- package/dist/resources/auth-middleware.js +39 -21
- package/dist/resources/embeddings-provider.js +33 -30
- package/dist/resources/federation-crypto.js +52 -0
- package/dist/resources/health.js +67 -1
- package/dist/src/keystore.js +116 -0
- package/package.json +5 -3
- package/schemas/agent.graphql +51 -2
- package/schemas/federation.graphql +51 -0
- package/schemas/memory.graphql +20 -0
- package/schemas/oauth.graphql +72 -0
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) =>
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
927
|
-
|
|
1594
|
+
let healthData = null;
|
|
1595
|
+
// 1. Basic health check (unauthenticated — just { ok: true })
|
|
928
1596
|
try {
|
|
929
|
-
const res = await fetch(`${baseUrl}/
|
|
930
|
-
healthy = res.
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
.
|
|
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
|
|
982
|
-
|
|
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
|
-
|
|
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
|
|
1284
|
-
.
|
|
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
|
|
1288
|
-
const
|
|
1289
|
-
const agentId = opts.agent;
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
|
1298
|
-
|
|
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(`
|
|
2165
|
+
console.log(` ${green("PASS")} ${name}`);
|
|
1306
2166
|
passed++;
|
|
1307
2167
|
}
|
|
1308
2168
|
else {
|
|
1309
|
-
console.log(`
|
|
2169
|
+
console.log(` ${red("FAIL")} ${name}`);
|
|
1310
2170
|
failed++;
|
|
1311
2171
|
}
|
|
1312
2172
|
}
|
|
1313
2173
|
catch (e) {
|
|
1314
|
-
console.log(`
|
|
2174
|
+
console.log(` ${red("FAIL")} ${name}: ${e.message?.slice(0, 120)}`);
|
|
1315
2175
|
failed++;
|
|
1316
2176
|
}
|
|
1317
2177
|
};
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
return
|
|
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
|
-
//
|
|
1344
|
-
await check("
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
//
|
|
1354
|
-
await check("Memory
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
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, };
|