@tpsdev-ai/flair 0.2.0 → 0.3.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 ADDED
@@ -0,0 +1,1087 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import nacl from "tweetnacl";
4
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
9
+ // ─── Defaults ────────────────────────────────────────────────────────────────
10
+ const DEFAULT_PORT = 9926;
11
+ const DEFAULT_ADMIN_USER = "admin";
12
+ const STARTUP_TIMEOUT_MS = 60_000;
13
+ const HEALTH_POLL_INTERVAL_MS = 500;
14
+ function defaultKeysDir() {
15
+ return join(homedir(), ".flair", "keys");
16
+ }
17
+ function defaultDataDir() {
18
+ return join(homedir(), ".flair", "data");
19
+ }
20
+ function privKeyPath(agentId, keysDir) {
21
+ return join(keysDir, `${agentId}.key`);
22
+ }
23
+ function pubKeyPath(agentId, keysDir) {
24
+ return join(keysDir, `${agentId}.pub`);
25
+ }
26
+ function harperBin() {
27
+ // Resolve relative to this file's location (dist/cli.js → ../node_modules/...)
28
+ const candidates = [
29
+ join(import.meta.dirname ?? __dirname, "..", "node_modules", "@harperfast", "harper", "dist", "bin", "harper.js"),
30
+ join(process.cwd(), "node_modules", "@harperfast", "harper", "dist", "bin", "harper.js"),
31
+ ];
32
+ for (const c of candidates)
33
+ if (existsSync(c))
34
+ return c;
35
+ return null;
36
+ }
37
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
38
+ function b64(bytes) {
39
+ return Buffer.from(bytes).toString("base64");
40
+ }
41
+ function b64url(bytes) {
42
+ return Buffer.from(bytes).toString("base64url");
43
+ }
44
+ async function api(method, path, body) {
45
+ const base = process.env.FLAIR_URL || "http://127.0.0.1:9926";
46
+ const token = process.env.FLAIR_TOKEN;
47
+ const res = await fetch(`${base}${path}`, {
48
+ method,
49
+ headers: {
50
+ "content-type": "application/json",
51
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
52
+ },
53
+ body: body ? JSON.stringify(body) : undefined,
54
+ });
55
+ const json = await res.json();
56
+ if (!res.ok)
57
+ throw new Error(JSON.stringify(json));
58
+ return json;
59
+ }
60
+ /** Find the agent's private key file from standard locations. */
61
+ function resolveKeyPath(agentId) {
62
+ const candidates = [
63
+ process.env.FLAIR_KEY_DIR ? join(process.env.FLAIR_KEY_DIR, `${agentId}.key`) : null,
64
+ join(homedir(), ".flair", "keys", `${agentId}.key`),
65
+ join(homedir(), ".tps", "secrets", "flair", `${agentId}-priv.key`),
66
+ ].filter(Boolean);
67
+ return candidates.find((p) => existsSync(p)) ?? null;
68
+ }
69
+ /** Build a TPS-Ed25519 auth header from a raw 32-byte seed on disk. */
70
+ function buildEd25519Auth(agentId, method, path, keyPath) {
71
+ const raw = readFileSync(keyPath);
72
+ const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex");
73
+ let privKey;
74
+ if (raw.length === 32) {
75
+ // Raw 32-byte seed
76
+ privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, raw]), format: "der", type: "pkcs8" });
77
+ }
78
+ else {
79
+ // Try as base64-encoded PKCS8 DER (standard Flair key format)
80
+ const decoded = Buffer.from(raw.toString("utf-8").trim(), "base64");
81
+ if (decoded.length === 32) {
82
+ // Base64-encoded raw seed
83
+ privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, decoded]), format: "der", type: "pkcs8" });
84
+ }
85
+ else {
86
+ // Full PKCS8 DER or PEM
87
+ try {
88
+ privKey = createPrivateKey({ key: decoded, format: "der", type: "pkcs8" });
89
+ }
90
+ catch {
91
+ privKey = createPrivateKey(raw);
92
+ }
93
+ }
94
+ }
95
+ const ts = Date.now().toString();
96
+ const nonce = randomUUID();
97
+ const payload = `${agentId}:${ts}:${nonce}:${method}:${path}`;
98
+ const sig = nodeCryptoSign(null, Buffer.from(payload), privKey).toString("base64");
99
+ return `TPS-Ed25519 ${agentId}:${ts}:${nonce}:${sig}`;
100
+ }
101
+ /** Authenticated fetch against Flair using Ed25519. */
102
+ async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
103
+ const auth = buildEd25519Auth(agentId, method, path, keyPath);
104
+ const headers = { Authorization: auth };
105
+ if (body !== undefined)
106
+ headers["Content-Type"] = "application/json";
107
+ return fetch(`${baseUrl}${path}`, {
108
+ method,
109
+ headers,
110
+ body: body !== undefined ? JSON.stringify(body) : undefined,
111
+ });
112
+ }
113
+ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
114
+ const url = `http://127.0.0.1:${httpPort}/health`;
115
+ const deadline = Date.now() + timeoutMs;
116
+ let attempt = 0;
117
+ while (Date.now() < deadline) {
118
+ attempt++;
119
+ try {
120
+ const res = await fetch(url, {
121
+ headers: { Authorization: `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}` },
122
+ signal: AbortSignal.timeout(2000),
123
+ });
124
+ if (res.status > 0)
125
+ return;
126
+ }
127
+ catch { /* not ready yet */ }
128
+ await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));
129
+ }
130
+ throw new Error(`Harper at port ${httpPort} did not respond within ${timeoutMs}ms (${attempt} attempts)`);
131
+ }
132
+ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass) {
133
+ const url = `http://127.0.0.1:${opsPort}/`;
134
+ const auth = Buffer.from(`${adminUser}:${adminPass}`).toString("base64");
135
+ const body = {
136
+ operation: "insert",
137
+ database: "flair",
138
+ table: "Agent",
139
+ records: [{ id: agentId, name: agentId, publicKey: pubKeyB64url, createdAt: new Date().toISOString() }],
140
+ };
141
+ const res = await fetch(url, {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
144
+ body: JSON.stringify(body),
145
+ });
146
+ if (!res.ok) {
147
+ const text = await res.text().catch(() => "");
148
+ if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
149
+ return;
150
+ throw new Error(`Operations API insert failed (${res.status}): ${text}`);
151
+ }
152
+ }
153
+ // ─── Program ─────────────────────────────────────────────────────────────────
154
+ const program = new Command();
155
+ program.name("flair");
156
+ // ─── flair init ──────────────────────────────────────────────────────────────
157
+ program
158
+ .command("init")
159
+ .description("Bootstrap a local Flair (Harper) instance for an agent")
160
+ .option("--agent-id <id>", "Agent ID to register", "local")
161
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
162
+ .option("--admin-pass <pass>", "Admin password (generated if omitted)")
163
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
164
+ .option("--data-dir <dir>", "Harper data directory")
165
+ .option("--skip-start", "Skip Harper startup (assume already running)")
166
+ .action(async (opts) => {
167
+ const agentId = opts.agentId;
168
+ const httpPort = Number(opts.port);
169
+ const opsPort = httpPort + 1;
170
+ const keysDir = opts.keysDir ?? defaultKeysDir();
171
+ const dataDir = opts.dataDir ?? defaultDataDir();
172
+ // Admin password: generate if not provided, NEVER written to disk
173
+ const adminPass = opts.adminPass ?? Buffer.from(nacl.randomBytes(18)).toString("base64url");
174
+ const adminUser = DEFAULT_ADMIN_USER;
175
+ // Check Node.js version
176
+ const major = parseInt(process.version.slice(1), 10);
177
+ if (major < 18)
178
+ throw new Error(`Node.js >= 18 required (found ${process.version})`);
179
+ let alreadyRunning = false;
180
+ if (!opts.skipStart) {
181
+ // Check if already running
182
+ try {
183
+ const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
184
+ if (res.status > 0) {
185
+ alreadyRunning = true;
186
+ console.log(`Harper already running on port ${httpPort} — skipping start`);
187
+ }
188
+ }
189
+ catch { /* not running */ }
190
+ if (!alreadyRunning) {
191
+ const bin = harperBin();
192
+ if (!bin)
193
+ throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
194
+ mkdirSync(dataDir, { recursive: true });
195
+ const env = {
196
+ ...process.env,
197
+ ROOTPATH: dataDir,
198
+ DEFAULTS_MODE: "dev",
199
+ HDB_ADMIN_USERNAME: adminUser,
200
+ HDB_ADMIN_PASSWORD: adminPass,
201
+ THREADS_COUNT: "1",
202
+ NODE_HOSTNAME: "localhost",
203
+ HTTP_PORT: String(httpPort),
204
+ OPERATIONSAPI_NETWORK_PORT: String(opsPort),
205
+ LOCAL_STUDIO: "false",
206
+ };
207
+ // Install
208
+ console.log("Installing Harper...");
209
+ await new Promise((resolve, reject) => {
210
+ let output = "";
211
+ const install = spawn(process.execPath, [bin, "install"], { cwd: process.cwd(), env });
212
+ install.stdout?.on("data", (d) => { output += d.toString(); });
213
+ install.stderr?.on("data", (d) => { output += d.toString(); });
214
+ install.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`)));
215
+ install.on("error", reject);
216
+ setTimeout(() => { install.kill(); reject(new Error(`Harper install timed out: ${output}`)); }, 20_000);
217
+ });
218
+ // Start (detached)
219
+ console.log(`Starting Harper on port ${httpPort}...`);
220
+ const proc = spawn(process.execPath, [bin, "dev", "."], { cwd: process.cwd(), env, detached: true, stdio: "ignore" });
221
+ proc.unref();
222
+ }
223
+ console.log("Waiting for Harper health check...");
224
+ await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
225
+ console.log("Harper is healthy ✓");
226
+ }
227
+ // Generate or reuse keypair
228
+ mkdirSync(keysDir, { recursive: true });
229
+ const privPath = privKeyPath(agentId, keysDir);
230
+ const pubPath = pubKeyPath(agentId, keysDir);
231
+ let pubKeyB64url;
232
+ if (existsSync(privPath)) {
233
+ console.log(`Reusing existing key: ${privPath}`);
234
+ const seed = new Uint8Array(readFileSync(privPath));
235
+ const kp = nacl.sign.keyPair.fromSeed(seed);
236
+ pubKeyB64url = b64url(kp.publicKey);
237
+ }
238
+ else {
239
+ console.log("Generating Ed25519 keypair...");
240
+ const kp = nacl.sign.keyPair();
241
+ // Store only the 32-byte seed (first 32 bytes of secretKey)
242
+ const seed = kp.secretKey.slice(0, 32);
243
+ writeFileSync(privPath, Buffer.from(seed));
244
+ chmodSync(privPath, 0o600);
245
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
246
+ pubKeyB64url = b64url(kp.publicKey);
247
+ console.log(`Keypair written: ${privPath} ✓`);
248
+ }
249
+ // Seed agent via operations API
250
+ console.log(`Seeding agent '${agentId}' via operations API...`);
251
+ await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass);
252
+ console.log(`Agent '${agentId}' registered ✓`);
253
+ // Verify Ed25519 auth
254
+ console.log("Verifying Ed25519 auth...");
255
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
256
+ const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
257
+ if (!verifyRes.ok)
258
+ throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
259
+ console.log("Ed25519 auth verified ✓");
260
+ // Output — admin password printed once, never written to disk
261
+ console.log("\n✅ Flair initialized successfully");
262
+ console.log(` Agent ID: ${agentId}`);
263
+ console.log(` Flair URL: ${httpUrl}`);
264
+ console.log(` Private key: ${privPath}`);
265
+ if (!opts.adminPass && !alreadyRunning) {
266
+ console.log(`\n⚠️ Admin password (save this — it won't be shown again):`);
267
+ console.log(` ${adminPass}`);
268
+ }
269
+ console.log(`\n Export: FLAIR_URL=${httpUrl}`);
270
+ });
271
+ // ─── flair agent ─────────────────────────────────────────────────────────────
272
+ const agent = program.command("agent").description("Manage Flair agents");
273
+ agent
274
+ .command("add <id>")
275
+ .description("Register a new agent in a running Flair instance")
276
+ .option("--name <name>", "Display name (defaults to id)")
277
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
278
+ .option("--admin-pass <pass>", "Admin password for registration")
279
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
280
+ .option("--ops-port <port>", "Harper operations API port")
281
+ .action(async (id, opts) => {
282
+ const httpPort = Number(opts.port);
283
+ const opsPort = opts.opsPort ? Number(opts.opsPort) : httpPort + 1;
284
+ const keysDir = opts.keysDir ?? defaultKeysDir();
285
+ const adminPass = opts.adminPass;
286
+ const adminUser = DEFAULT_ADMIN_USER;
287
+ const name = opts.name ?? id;
288
+ if (!adminPass) {
289
+ console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table)");
290
+ process.exit(1);
291
+ }
292
+ mkdirSync(keysDir, { recursive: true });
293
+ const privPath = privKeyPath(id, keysDir);
294
+ const pubPath = pubKeyPath(id, keysDir);
295
+ let pubKeyB64url;
296
+ if (existsSync(privPath)) {
297
+ console.log(`Reusing existing key: ${privPath}`);
298
+ const seed = new Uint8Array(readFileSync(privPath));
299
+ const kp = nacl.sign.keyPair.fromSeed(seed);
300
+ pubKeyB64url = b64url(kp.publicKey);
301
+ }
302
+ else {
303
+ const kp = nacl.sign.keyPair();
304
+ const seed = kp.secretKey.slice(0, 32);
305
+ writeFileSync(privPath, Buffer.from(seed));
306
+ chmodSync(privPath, 0o600);
307
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
308
+ pubKeyB64url = b64url(kp.publicKey);
309
+ console.log(`Keypair written: ${privPath}`);
310
+ }
311
+ await seedAgentViaOpsApi(opsPort, id, pubKeyB64url, adminUser, adminPass);
312
+ console.log(`✅ Agent '${id}' (${name}) registered`);
313
+ console.log(` Private key: ${privPath}`);
314
+ console.log(` Public key: ${pubKeyB64url}`);
315
+ });
316
+ agent
317
+ .command("list")
318
+ .description("List all agents")
319
+ .action(async () => console.log(JSON.stringify(await api("GET", "/Agent"), null, 2)));
320
+ agent
321
+ .command("show <id>")
322
+ .description("Show agent details")
323
+ .action(async (id) => console.log(JSON.stringify(await api("GET", `/Agent/${id}`), null, 2)));
324
+ agent
325
+ .command("rotate-key <id>")
326
+ .description("Rotate an agent's Ed25519 keypair")
327
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
328
+ .option("--ops-port <port>", "Harper operations API port")
329
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
330
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
331
+ .action(async (id, opts) => {
332
+ const httpPort = Number(opts.port);
333
+ const opsPort = opts.opsPort ? Number(opts.opsPort) : httpPort + 1;
334
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
335
+ const adminUser = DEFAULT_ADMIN_USER;
336
+ const keysDir = opts.keysDir ?? defaultKeysDir();
337
+ if (!adminPass) {
338
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for key rotation");
339
+ process.exit(1);
340
+ }
341
+ mkdirSync(keysDir, { recursive: true });
342
+ const currentPrivPath = privKeyPath(id, keysDir);
343
+ const currentPubPath = pubKeyPath(id, keysDir);
344
+ const backupPrivPath = currentPrivPath + ".bak";
345
+ // Generate new keypair
346
+ console.log(`Generating new keypair for agent '${id}'...`);
347
+ const kp = nacl.sign.keyPair();
348
+ const newSeed = kp.secretKey.slice(0, 32);
349
+ const newPubKeyB64url = b64url(kp.publicKey);
350
+ // Back up old key if it exists
351
+ if (existsSync(currentPrivPath)) {
352
+ writeFileSync(backupPrivPath, readFileSync(currentPrivPath));
353
+ chmodSync(backupPrivPath, 0o600);
354
+ console.log(`Old key backed up to: ${backupPrivPath}`);
355
+ }
356
+ // Update publicKey in Flair via operations API
357
+ console.log(`Updating public key in Flair via operations API...`);
358
+ const opsUrl = `http://127.0.0.1:${opsPort}/`;
359
+ const auth = Buffer.from(`${adminUser}:${adminPass}`).toString("base64");
360
+ const updateBody = {
361
+ operation: "update",
362
+ database: "flair",
363
+ table: "Agent",
364
+ records: [{ id, publicKey: newPubKeyB64url, updatedAt: new Date().toISOString() }],
365
+ };
366
+ const updateRes = await fetch(opsUrl, {
367
+ method: "POST",
368
+ headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
369
+ body: JSON.stringify(updateBody),
370
+ signal: AbortSignal.timeout(10_000),
371
+ });
372
+ if (!updateRes.ok) {
373
+ const text = await updateRes.text().catch(() => "");
374
+ // Roll back: keep old key in place (don't write new key yet)
375
+ if (existsSync(backupPrivPath)) {
376
+ // Restore not needed — we haven't written new key yet
377
+ }
378
+ throw new Error(`Failed to update public key in Flair (${updateRes.status}): ${text}`);
379
+ }
380
+ console.log(`Public key updated in Flair ✓`);
381
+ // Write new private key (only after Flair update succeeds)
382
+ writeFileSync(currentPrivPath, Buffer.from(newSeed));
383
+ chmodSync(currentPrivPath, 0o600);
384
+ writeFileSync(currentPubPath, Buffer.from(kp.publicKey));
385
+ console.log(`New private key written: ${currentPrivPath} ✓`);
386
+ // Verify new key works
387
+ console.log(`Verifying new Ed25519 auth...`);
388
+ const httpUrl = `http://127.0.0.1:${httpPort}`;
389
+ const verifyRes = await authFetch(httpUrl, id, currentPrivPath, "GET", `/Agent/${id}`);
390
+ if (!verifyRes.ok) {
391
+ console.error(`⚠️ Auth verification failed (${verifyRes.status}). Old key is backed up at: ${backupPrivPath}`);
392
+ process.exit(1);
393
+ }
394
+ console.log(`Ed25519 auth verified ✓`);
395
+ console.log(`\n✅ Key rotation complete for agent '${id}'`);
396
+ console.log(` New public key: ${newPubKeyB64url}`);
397
+ console.log(` Private key: ${currentPrivPath}`);
398
+ console.log(` Old key backup: ${backupPrivPath}`);
399
+ });
400
+ // ─── flair agent remove ──────────────────────────────────────────────────────
401
+ agent
402
+ .command("remove <id>")
403
+ .description("Remove an agent and all its data from Flair")
404
+ .option("--keep-keys", "Do not delete key files from disk")
405
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
406
+ .option("--ops-port <port>", "Harper operations API port")
407
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
408
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys")
409
+ .option("--force", "Skip interactive confirmation (required when stdin is not a TTY)")
410
+ .action(async (id, opts) => {
411
+ const opsPort = opts.opsPort ? Number(opts.opsPort) : Number(opts.port) + 1;
412
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
413
+ const adminUser = DEFAULT_ADMIN_USER;
414
+ const keysDir = opts.keysDir ?? defaultKeysDir();
415
+ if (!adminPass) {
416
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for agent remove");
417
+ process.exit(1);
418
+ }
419
+ const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
420
+ async function opsPost(body) {
421
+ return fetch(`http://127.0.0.1:${opsPort}/`, {
422
+ method: "POST",
423
+ headers: { "Content-Type": "application/json", Authorization: auth },
424
+ body: JSON.stringify(body),
425
+ signal: AbortSignal.timeout(10_000),
426
+ });
427
+ }
428
+ // Fetch agent info and memory count for confirmation
429
+ const agentRes = await opsPost({ operation: "search_by_value", database: "flair", table: "Agent", search_attribute: "id", search_value: id, get_attributes: ["id", "name"] });
430
+ const agentData = agentRes.ok ? await agentRes.json().catch(() => null) : null;
431
+ const agentName = agentData?.[0]?.name ?? id;
432
+ const memRes = await opsPost({ operation: "search_by_value", database: "flair", table: "Memory", search_attribute: "agentId", search_value: id, get_attributes: ["id"] });
433
+ const memories = memRes.ok ? await memRes.json().catch(() => []) : [];
434
+ const memoryCount = Array.isArray(memories) ? memories.length : 0;
435
+ // Confirmation
436
+ const isInteractive = process.stdin.isTTY;
437
+ if (!opts.force) {
438
+ if (!isInteractive) {
439
+ console.error("Error: stdin is not a TTY. Use --force to skip confirmation.");
440
+ process.exit(1);
441
+ }
442
+ console.log(`⚠️ About to permanently remove agent '${agentName}' (${id})`);
443
+ console.log(` Memories to delete: ${memoryCount}`);
444
+ process.stdout.write(`\nType 'yes' to confirm: `);
445
+ const answer = await new Promise((resolve) => {
446
+ let buf = "";
447
+ process.stdin.setEncoding("utf-8");
448
+ process.stdin.resume();
449
+ process.stdin.on("data", (chunk) => {
450
+ buf += chunk;
451
+ if (buf.includes("\n")) {
452
+ process.stdin.pause();
453
+ resolve(buf.trim());
454
+ }
455
+ });
456
+ });
457
+ if (answer !== "yes") {
458
+ console.log("Aborted.");
459
+ process.exit(0);
460
+ }
461
+ }
462
+ else {
463
+ console.log(`Removing agent '${agentName}' (${id}) with ${memoryCount} memories...`);
464
+ }
465
+ // Delete all memories
466
+ if (memoryCount > 0) {
467
+ console.log(`Deleting ${memoryCount} memories...`);
468
+ for (const mem of (Array.isArray(memories) ? memories : [])) {
469
+ if (!mem?.id)
470
+ continue;
471
+ await opsPost({ operation: "delete", database: "flair", table: "Memory", ids: [mem.id] }).catch(() => { });
472
+ }
473
+ }
474
+ // Delete all souls
475
+ const soulRes = await opsPost({ operation: "search_by_value", database: "flair", table: "Soul", search_attribute: "agentId", search_value: id, get_attributes: ["id"] });
476
+ const souls = soulRes.ok ? await soulRes.json().catch(() => []) : [];
477
+ if (Array.isArray(souls) && souls.length > 0) {
478
+ console.log(`Deleting ${souls.length} soul entries...`);
479
+ for (const soul of souls) {
480
+ if (!soul?.id)
481
+ continue;
482
+ await opsPost({ operation: "delete", database: "flair", table: "Soul", ids: [soul.id] }).catch(() => { });
483
+ }
484
+ }
485
+ // Delete agent record
486
+ const delRes = await opsPost({ operation: "delete", database: "flair", table: "Agent", ids: [id] });
487
+ if (!delRes.ok) {
488
+ const text = await delRes.text().catch(() => "");
489
+ throw new Error(`Failed to delete agent record (${delRes.status}): ${text}`);
490
+ }
491
+ // Delete key files (unless --keep-keys)
492
+ if (!opts.keepKeys) {
493
+ const privPath = privKeyPath(id, keysDir);
494
+ const pubPath = pubKeyPath(id, keysDir);
495
+ const backupPath = privPath + ".bak";
496
+ for (const p of [privPath, pubPath, backupPath]) {
497
+ if (existsSync(p)) {
498
+ try {
499
+ const { unlinkSync: ul } = await import("node:fs");
500
+ ul(p);
501
+ }
502
+ catch { /* best effort */ }
503
+ }
504
+ }
505
+ console.log("Key files deleted.");
506
+ }
507
+ else {
508
+ console.log("Key files preserved (--keep-keys).");
509
+ }
510
+ console.log(`\n✅ Agent '${id}' removed successfully`);
511
+ });
512
+ // ─── flair grant / revoke ─────────────────────────────────────────────────────
513
+ program
514
+ .command("grant <from-agent> <to-agent>")
515
+ .description("Grant an agent read access to another agent's memories")
516
+ .option("--scope <scope>", "Grant scope: read or search", "read")
517
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
518
+ .option("--ops-port <port>", "Harper operations API port")
519
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
520
+ .option("--keys-dir <dir>", "Directory for Ed25519 keys (for from-agent Ed25519 auth)")
521
+ .action(async (fromAgent, toAgent, opts) => {
522
+ const httpPort = Number(opts.port);
523
+ const opsPort = opts.opsPort ? Number(opts.opsPort) : httpPort + 1;
524
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
525
+ const adminUser = DEFAULT_ADMIN_USER;
526
+ const scope = opts.scope ?? "read";
527
+ if (!adminPass) {
528
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for grant");
529
+ process.exit(1);
530
+ }
531
+ const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
532
+ const grantId = `${fromAgent}:${toAgent}`;
533
+ const body = {
534
+ operation: "insert",
535
+ database: "flair",
536
+ table: "MemoryGrant",
537
+ records: [{
538
+ id: grantId,
539
+ fromAgentId: fromAgent,
540
+ toAgentId: toAgent,
541
+ scope,
542
+ createdAt: new Date().toISOString(),
543
+ }],
544
+ };
545
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
546
+ method: "POST",
547
+ headers: { "Content-Type": "application/json", Authorization: auth },
548
+ body: JSON.stringify(body),
549
+ signal: AbortSignal.timeout(10_000),
550
+ });
551
+ if (!res.ok) {
552
+ const text = await res.text().catch(() => "");
553
+ if (res.status === 409 || text.includes("duplicate") || text.includes("already exists")) {
554
+ console.log(`ℹ️ Grant already exists: '${toAgent}' can already read '${fromAgent}'s memories`);
555
+ return;
556
+ }
557
+ throw new Error(`Failed to create grant (${res.status}): ${text}`);
558
+ }
559
+ console.log(`✅ Grant created: '${toAgent}' can now read '${fromAgent}'s memories`);
560
+ console.log(` ID: ${grantId}`);
561
+ console.log(` Scope: ${scope}`);
562
+ });
563
+ program
564
+ .command("revoke <from-agent> <to-agent>")
565
+ .description("Revoke a memory grant between two agents")
566
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
567
+ .option("--ops-port <port>", "Harper operations API port")
568
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
569
+ .action(async (fromAgent, toAgent, opts) => {
570
+ const httpPort = Number(opts.port);
571
+ const opsPort = opts.opsPort ? Number(opts.opsPort) : httpPort + 1;
572
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
573
+ const adminUser = DEFAULT_ADMIN_USER;
574
+ if (!adminPass) {
575
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for revoke");
576
+ process.exit(1);
577
+ }
578
+ const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
579
+ const grantId = `${fromAgent}:${toAgent}`;
580
+ const body = {
581
+ operation: "delete",
582
+ database: "flair",
583
+ table: "MemoryGrant",
584
+ ids: [grantId],
585
+ };
586
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
587
+ method: "POST",
588
+ headers: { "Content-Type": "application/json", Authorization: auth },
589
+ body: JSON.stringify(body),
590
+ signal: AbortSignal.timeout(10_000),
591
+ });
592
+ if (!res.ok) {
593
+ const text = await res.text().catch(() => "");
594
+ if (res.status === 404 || text.includes("not found")) {
595
+ console.log(`ℹ️ No grant found: '${toAgent}' does not have access to '${fromAgent}'s memories`);
596
+ return;
597
+ }
598
+ throw new Error(`Failed to revoke grant (${res.status}): ${text}`);
599
+ }
600
+ console.log(`✅ Grant revoked: '${toAgent}' can no longer read '${fromAgent}'s memories`);
601
+ console.log(` Removed grant ID: ${grantId}`);
602
+ });
603
+ // ─── flair status ─────────────────────────────────────────────────────────────
604
+ program
605
+ .command("status")
606
+ .description("Check Flair (Harper) instance health and agent count")
607
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
608
+ .option("--url <url>", "Flair base URL (overrides --port)")
609
+ .action(async (opts) => {
610
+ const baseUrl = opts.url ?? `http://127.0.0.1:${opts.port}`;
611
+ let healthy = false;
612
+ let agentCount = null;
613
+ let version = null;
614
+ try {
615
+ const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(3000) });
616
+ healthy = res.status > 0;
617
+ if (res.headers.get("content-type")?.includes("application/json")) {
618
+ const body = await res.json().catch(() => null);
619
+ if (body?.version)
620
+ version = body.version;
621
+ }
622
+ }
623
+ catch { /* unreachable */ }
624
+ if (healthy) {
625
+ try {
626
+ const agents = await fetch(`${baseUrl}/Agent`, { signal: AbortSignal.timeout(3000) });
627
+ if (agents.ok) {
628
+ const list = await agents.json().catch(() => null);
629
+ if (Array.isArray(list))
630
+ agentCount = list.length;
631
+ }
632
+ }
633
+ catch { /* best effort */ }
634
+ }
635
+ const status = healthy ? "🟢 running" : "🔴 unreachable";
636
+ console.log(`Flair status: ${status}`);
637
+ console.log(` URL: ${baseUrl}`);
638
+ if (version)
639
+ console.log(` Version: ${version}`);
640
+ if (agentCount !== null)
641
+ console.log(` Agents: ${agentCount}`);
642
+ if (!healthy)
643
+ process.exit(1);
644
+ });
645
+ // ─── Legacy identity/memory/soul commands (preserved) ────────────────────────
646
+ const identity = program.command("identity").description("Legacy identity commands");
647
+ identity.command("register")
648
+ .requiredOption("--id <id>")
649
+ .requiredOption("--name <name>")
650
+ .option("--role <role>")
651
+ .action(async (opts) => {
652
+ const kp = nacl.sign.keyPair();
653
+ const now = new Date().toISOString();
654
+ const agentRecord = await api("POST", "/Agent", {
655
+ id: opts.id, name: opts.name, role: opts.role,
656
+ publicKey: b64(kp.publicKey), createdAt: now, updatedAt: now,
657
+ });
658
+ console.log(JSON.stringify({ agent: agentRecord, privateKey: b64(kp.secretKey) }, null, 2));
659
+ });
660
+ identity.command("show").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Agent/${id}`), null, 2)));
661
+ identity.command("list").action(async () => console.log(JSON.stringify(await api("GET", "/Agent"), null, 2)));
662
+ identity.command("add-integration")
663
+ .requiredOption("--agent <agentId>")
664
+ .requiredOption("--platform <platform>")
665
+ .requiredOption("--encrypted-credential <ciphertext>")
666
+ .action(async (opts) => {
667
+ const now = new Date().toISOString();
668
+ const out = await api("POST", "/Integration", {
669
+ id: `${opts.agent}:${opts.platform}`, agentId: opts.agent,
670
+ platform: opts.platform, encryptedCredential: opts.encryptedCredential,
671
+ createdAt: now, updatedAt: now,
672
+ });
673
+ console.log(JSON.stringify(out, null, 2));
674
+ });
675
+ const memory = program.command("memory").description("Manage agent memories");
676
+ memory.command("add").requiredOption("--agent <id>").requiredOption("--content <text>")
677
+ .option("--durability <d>", "standard").option("--tags <csv>")
678
+ .action(async (opts) => {
679
+ const out = await api("POST", "/Memory", {
680
+ agentId: opts.agent, content: opts.content, durability: opts.durability,
681
+ tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
682
+ });
683
+ console.log(JSON.stringify(out, null, 2));
684
+ });
685
+ memory.command("search").requiredOption("--agent <id>").requiredOption("--q <query>").option("--tag <tag>")
686
+ .action(async (opts) => console.log(JSON.stringify(await api("POST", "/MemorySearch", { agentId: opts.agent, q: opts.q, tag: opts.tag }), null, 2)));
687
+ memory.command("list").requiredOption("--agent <id>").option("--tag <tag>")
688
+ .action(async (opts) => {
689
+ const q = new URLSearchParams({ agentId: opts.agent, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
690
+ console.log(JSON.stringify(await api("GET", `/Memory?${q}`), null, 2));
691
+ });
692
+ // ─── flair search (top-level shortcut) ───────────────────────────────────────
693
+ program
694
+ .command("search <query>")
695
+ .description("Search memories by meaning (shortcut for memory search)")
696
+ .requiredOption("--agent <id>", "Agent ID")
697
+ .option("--limit <n>", "Max results", "5")
698
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
699
+ .option("--url <url>", "Flair base URL (overrides --port)")
700
+ .option("--key <path>", "Ed25519 private key path")
701
+ .action(async (query, opts) => {
702
+ try {
703
+ const baseUrl = opts.url || `http://127.0.0.1:${opts.port}`;
704
+ const headers = { "content-type": "application/json" };
705
+ const keyPath = opts.key || resolveKeyPath(opts.agent);
706
+ if (keyPath) {
707
+ headers["authorization"] = buildEd25519Auth(opts.agent, "POST", "/SemanticSearch", keyPath);
708
+ }
709
+ const res = await fetch(`${baseUrl}/SemanticSearch`, {
710
+ method: "POST",
711
+ headers,
712
+ body: JSON.stringify({ agentId: opts.agent, q: query, limit: parseInt(opts.limit, 10) }),
713
+ });
714
+ if (!res.ok)
715
+ throw new Error(await res.text());
716
+ const result = await res.json();
717
+ const results = result.results || result || [];
718
+ if (!Array.isArray(results) || results.length === 0) {
719
+ console.log("No results found.");
720
+ return;
721
+ }
722
+ for (const r of results) {
723
+ const date = r.createdAt ? r.createdAt.slice(0, 10) : "";
724
+ const score = r._score ? `${(r._score * 100).toFixed(0)}%` : "";
725
+ const meta = [date, r.type, score].filter(Boolean).join(" · ");
726
+ console.log(` ${r.content}`);
727
+ if (meta)
728
+ console.log(` (${meta})`);
729
+ console.log();
730
+ }
731
+ }
732
+ catch (err) {
733
+ console.error(`Search failed: ${err.message}`);
734
+ process.exit(1);
735
+ }
736
+ });
737
+ // ─── flair bootstrap ─────────────────────────────────────────────────────────
738
+ program
739
+ .command("bootstrap")
740
+ .description("Cold-start context: get soul + recent memories as formatted text")
741
+ .requiredOption("--agent <id>", "Agent ID")
742
+ .option("--max-tokens <n>", "Maximum tokens in output", "4000")
743
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
744
+ .option("--url <url>", "Flair base URL (overrides --port)")
745
+ .option("--key <path>", "Ed25519 private key path")
746
+ .action(async (opts) => {
747
+ const baseUrl = opts.url || `http://127.0.0.1:${opts.port}`;
748
+ try {
749
+ const headers = { "content-type": "application/json" };
750
+ const keyPath = opts.key || resolveKeyPath(opts.agent);
751
+ if (keyPath) {
752
+ headers["authorization"] = buildEd25519Auth(opts.agent, "POST", "/BootstrapMemories", keyPath);
753
+ }
754
+ const res = await fetch(`${baseUrl}/BootstrapMemories`, {
755
+ method: "POST",
756
+ headers,
757
+ body: JSON.stringify({ agentId: opts.agent, maxTokens: parseInt(opts.maxTokens, 10) }),
758
+ });
759
+ if (!res.ok) {
760
+ const body = await res.text();
761
+ throw new Error(`${res.status}: ${body}`);
762
+ }
763
+ const result = await res.json();
764
+ if (result.context) {
765
+ console.log(result.context);
766
+ }
767
+ else {
768
+ console.error("No context available.");
769
+ process.exit(1);
770
+ }
771
+ }
772
+ catch (err) {
773
+ console.error(`Bootstrap failed: ${err.message}`);
774
+ process.exit(1);
775
+ }
776
+ });
777
+ const soul = program.command("soul").description("Manage agent soul entries");
778
+ soul.command("set").requiredOption("--agent <id>").requiredOption("--key <key>").requiredOption("--value <value>")
779
+ .option("--durability <d>", "permanent")
780
+ .action(async (opts) => {
781
+ const out = await api("POST", "/Soul", { id: `${opts.agent}:${opts.key}`, agentId: opts.agent, key: opts.key, value: opts.value, durability: opts.durability });
782
+ console.log(JSON.stringify(out, null, 2));
783
+ });
784
+ soul.command("get").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Soul/${id}`), null, 2)));
785
+ soul.command("list").requiredOption("--agent <id>")
786
+ .action(async (opts) => console.log(JSON.stringify(await api("GET", `/Soul?agentId=${encodeURIComponent(opts.agent)}`), null, 2)));
787
+ // ─── flair backup ────────────────────────────────────────────────────────────
788
+ program
789
+ .command("backup")
790
+ .description("Export agents, memories, and souls to a JSON archive")
791
+ .option("--output <path>", "Output file path (default: ~/.flair/backups/flair-backup-<timestamp>.json)")
792
+ .option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
793
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
794
+ .option("--url <url>", "Flair base URL (overrides --port)")
795
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
796
+ .action(async (opts) => {
797
+ const baseUrl = opts.url ?? `http://127.0.0.1:${opts.port}`;
798
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
799
+ const adminUser = DEFAULT_ADMIN_USER;
800
+ if (!adminPass) {
801
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for backup");
802
+ process.exit(1);
803
+ }
804
+ const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
805
+ async function adminGet(path) {
806
+ const res = await fetch(`${baseUrl}${path}`, {
807
+ headers: { Authorization: auth },
808
+ signal: AbortSignal.timeout(10_000),
809
+ });
810
+ if (!res.ok) {
811
+ const text = await res.text().catch(() => "");
812
+ throw new Error(`GET ${path} failed (${res.status}): ${text}`);
813
+ }
814
+ return res.json();
815
+ }
816
+ console.log("Fetching agents...");
817
+ const allAgents = await adminGet("/Agent/");
818
+ const filterIds = opts.agents ? opts.agents.split(",").map((s) => s.trim()) : null;
819
+ const agents = filterIds ? allAgents.filter((a) => filterIds.includes(a.id)) : allAgents;
820
+ console.log(`Fetching memories for ${agents.length} agent(s)...`);
821
+ const memories = [];
822
+ for (const agent of agents) {
823
+ try {
824
+ const agentMemories = await adminGet(`/Memory/?agentId=${encodeURIComponent(agent.id)}`);
825
+ if (Array.isArray(agentMemories))
826
+ memories.push(...agentMemories);
827
+ }
828
+ catch (err) {
829
+ console.warn(` Warning: could not fetch memories for ${agent.id}: ${err.message}`);
830
+ }
831
+ }
832
+ console.log("Fetching souls...");
833
+ const souls = [];
834
+ for (const agent of agents) {
835
+ try {
836
+ const agentSouls = await adminGet(`/Soul/?agentId=${encodeURIComponent(agent.id)}`);
837
+ if (Array.isArray(agentSouls))
838
+ souls.push(...agentSouls);
839
+ }
840
+ catch (err) {
841
+ console.warn(` Warning: could not fetch souls for ${agent.id}: ${err.message}`);
842
+ }
843
+ }
844
+ const backup = {
845
+ version: 1,
846
+ createdAt: new Date().toISOString(),
847
+ source: baseUrl,
848
+ agents,
849
+ memories,
850
+ souls,
851
+ };
852
+ // Determine output path
853
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
854
+ const defaultOutput = join(homedir(), ".flair", "backups", `flair-backup-${timestamp}.json`);
855
+ const outputPath = opts.output ?? defaultOutput;
856
+ mkdirSync(join(outputPath, ".."), { recursive: true });
857
+ const tmp = outputPath + ".tmp";
858
+ writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
859
+ renameSync(tmp, outputPath);
860
+ console.log(`\n✅ Backup complete`);
861
+ console.log(` Agents: ${agents.length}`);
862
+ console.log(` Memories: ${memories.length}`);
863
+ console.log(` Souls: ${souls.length}`);
864
+ console.log(` Output: ${outputPath}`);
865
+ });
866
+ // ─── flair restore ────────────────────────────────────────────────────────────
867
+ program
868
+ .command("restore <path>")
869
+ .description("Import a Flair backup archive")
870
+ .option("--merge", "Add/update records without deleting existing (default)")
871
+ .option("--replace", "Delete all existing data for backed-up agents first, then import")
872
+ .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
873
+ .option("--url <url>", "Flair base URL (overrides --port)")
874
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
875
+ .option("--dry-run", "Show what would be imported without making changes")
876
+ .action(async (backupPath, opts) => {
877
+ const baseUrl = opts.url ?? `http://127.0.0.1:${opts.port}`;
878
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
879
+ const adminUser = DEFAULT_ADMIN_USER;
880
+ const dryRun = Boolean(opts.dryRun);
881
+ const mode = opts.replace ? "replace" : "merge";
882
+ if (!adminPass) {
883
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for restore");
884
+ process.exit(1);
885
+ }
886
+ if (!existsSync(backupPath)) {
887
+ console.error(`Error: backup file not found: ${backupPath}`);
888
+ process.exit(1);
889
+ }
890
+ const backup = JSON.parse(readFileSync(backupPath, "utf-8"));
891
+ if (backup.version !== 1) {
892
+ console.error(`Error: unsupported backup version: ${backup.version}`);
893
+ process.exit(1);
894
+ }
895
+ const { agents = [], memories = [], souls = [] } = backup;
896
+ const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
897
+ console.log(`Restoring from: ${backupPath}`);
898
+ console.log(`Mode: ${mode}${dryRun ? " (dry run)" : ""}`);
899
+ console.log(` Agents: ${agents.length}`);
900
+ console.log(` Memories: ${memories.length}`);
901
+ console.log(` Souls: ${souls.length}`);
902
+ if (dryRun) {
903
+ console.log("\n✅ Dry run complete — no changes made");
904
+ return;
905
+ }
906
+ async function adminPut(path, body) {
907
+ const res = await fetch(`${baseUrl}${path}`, {
908
+ method: "PUT",
909
+ headers: { "Content-Type": "application/json", Authorization: auth },
910
+ body: JSON.stringify(body),
911
+ signal: AbortSignal.timeout(10_000),
912
+ });
913
+ if (!res.ok) {
914
+ const text = await res.text().catch(() => "");
915
+ throw new Error(`PUT ${path} failed (${res.status}): ${text}`);
916
+ }
917
+ }
918
+ async function adminDelete(path) {
919
+ const res = await fetch(`${baseUrl}${path}`, {
920
+ method: "DELETE",
921
+ headers: { Authorization: auth },
922
+ signal: AbortSignal.timeout(10_000),
923
+ });
924
+ // 404 is fine — already gone
925
+ if (!res.ok && res.status !== 404) {
926
+ const text = await res.text().catch(() => "");
927
+ throw new Error(`DELETE ${path} failed (${res.status}): ${text}`);
928
+ }
929
+ }
930
+ // Replace mode: delete existing data for these agents first
931
+ if (mode === "replace") {
932
+ console.log("\nDeleting existing data (replace mode)...");
933
+ for (const memory of memories) {
934
+ if (memory.id)
935
+ await adminDelete(`/Memory/${memory.id}`).catch((e) => console.warn(` warn: ${e.message}`));
936
+ }
937
+ for (const soul of souls) {
938
+ if (soul.id)
939
+ await adminDelete(`/Soul/${soul.id}`).catch((e) => console.warn(` warn: ${e.message}`));
940
+ }
941
+ }
942
+ // Restore agents
943
+ console.log("\nRestoring agents...");
944
+ let agentCount = 0;
945
+ for (const agent of agents) {
946
+ try {
947
+ await adminPut(`/Agent/${agent.id}`, agent);
948
+ agentCount++;
949
+ }
950
+ catch (err) {
951
+ console.warn(` warn: agent ${agent.id}: ${err.message}`);
952
+ }
953
+ }
954
+ // Restore memories
955
+ console.log("Restoring memories...");
956
+ let memoryCount = 0;
957
+ for (const memory of memories) {
958
+ try {
959
+ await adminPut(`/Memory/${memory.id}`, memory);
960
+ memoryCount++;
961
+ }
962
+ catch (err) {
963
+ console.warn(` warn: memory ${memory.id}: ${err.message}`);
964
+ }
965
+ }
966
+ // Restore souls
967
+ console.log("Restoring souls...");
968
+ let soulCount = 0;
969
+ for (const soul of souls) {
970
+ try {
971
+ await adminPut(`/Soul/${soul.id}`, soul);
972
+ soulCount++;
973
+ }
974
+ catch (err) {
975
+ console.warn(` warn: soul ${soul.id}: ${err.message}`);
976
+ }
977
+ }
978
+ console.log(`\n✅ Restore complete`);
979
+ console.log(` Agents restored: ${agentCount}/${agents.length}`);
980
+ console.log(` Memories restored: ${memoryCount}/${memories.length}`);
981
+ console.log(` Souls restored: ${soulCount}/${souls.length}`);
982
+ });
983
+ // ─── flair migrate-keys ───────────────────────────────────────────────────────
984
+ program
985
+ .command("migrate-keys")
986
+ .description("Migrate agent keys from legacy path (~/.tps/secrets/flair/) to ~/.flair/keys/")
987
+ .option("--from <dir>", "Legacy keys directory", join(homedir(), ".tps", "secrets", "flair"))
988
+ .option("--to <dir>", "New keys directory", defaultKeysDir())
989
+ .option("--dry-run", "Show what would be migrated without copying")
990
+ .option("--port <port>", "Harper HTTP port (for agent list)", String(DEFAULT_PORT))
991
+ .option("--ops-port <port>", "Harper operations API port")
992
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
993
+ .action(async (opts) => {
994
+ const fromDir = opts.from;
995
+ const toDir = opts.to;
996
+ const dryRun = opts.dryRun ?? false;
997
+ const opsPort = opts.opsPort ? Number(opts.opsPort) : Number(opts.port) + 1;
998
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
999
+ if (!existsSync(fromDir)) {
1000
+ console.log(`Legacy keys directory not found: ${fromDir}`);
1001
+ console.log("Nothing to migrate.");
1002
+ process.exit(0);
1003
+ }
1004
+ // Discover legacy keys: <id>-priv.key pattern
1005
+ const { readdirSync, unlinkSync } = await import("node:fs");
1006
+ const files = readdirSync(fromDir);
1007
+ const keyFiles = files.filter((f) => f.endsWith("-priv.key"));
1008
+ if (keyFiles.length === 0) {
1009
+ console.log(`No legacy key files found in ${fromDir}`);
1010
+ process.exit(0);
1011
+ }
1012
+ console.log(`Found ${keyFiles.length} legacy key file(s) in ${fromDir}:`);
1013
+ let migrated = 0;
1014
+ let skipped = 0;
1015
+ for (const file of keyFiles) {
1016
+ const agentId = file.replace("-priv.key", "");
1017
+ const srcPath = join(fromDir, file);
1018
+ const destPath = join(toDir, `${agentId}.key`);
1019
+ if (existsSync(destPath)) {
1020
+ console.log(` skip: ${agentId} — already exists at ${destPath}`);
1021
+ skipped++;
1022
+ continue;
1023
+ }
1024
+ if (dryRun) {
1025
+ console.log(` would migrate: ${srcPath} → ${destPath}`);
1026
+ migrated++;
1027
+ continue;
1028
+ }
1029
+ mkdirSync(toDir, { recursive: true });
1030
+ const keyData = readFileSync(srcPath);
1031
+ writeFileSync(destPath, keyData);
1032
+ chmodSync(destPath, 0o600);
1033
+ console.log(` migrated: ${agentId} → ${destPath}`);
1034
+ migrated++;
1035
+ }
1036
+ if (dryRun) {
1037
+ console.log(`\n🔍 Dry run: ${migrated} key(s) would be migrated, ${skipped} skipped`);
1038
+ }
1039
+ else {
1040
+ console.log(`\n✅ Migration complete: ${migrated} migrated, ${skipped} skipped`);
1041
+ if (migrated > 0) {
1042
+ console.log(`\nLegacy keys preserved at ${fromDir} (delete manually when confirmed working).`);
1043
+ // Optionally verify keys match Flair records
1044
+ if (adminPass) {
1045
+ console.log("\nVerifying migrated keys against Flair...");
1046
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
1047
+ for (const file of keyFiles) {
1048
+ const agentId = file.replace("-priv.key", "");
1049
+ const destPath = join(toDir, `${agentId}.key`);
1050
+ if (!existsSync(destPath))
1051
+ continue;
1052
+ try {
1053
+ const keyB64 = readFileSync(destPath, "utf-8").trim();
1054
+ const seed = Buffer.from(keyB64, "base64");
1055
+ const kp = nacl.sign.keyPair.fromSeed(new Uint8Array(seed));
1056
+ const localPub = b64url(kp.publicKey);
1057
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
1058
+ method: "POST",
1059
+ headers: { "Content-Type": "application/json", Authorization: auth },
1060
+ body: JSON.stringify({ operation: "search_by_value", database: "flair", table: "Agent", search_attribute: "id", search_value: agentId, get_attributes: ["id", "publicKey"] }),
1061
+ signal: AbortSignal.timeout(10_000),
1062
+ });
1063
+ if (res.ok) {
1064
+ const agents = await res.json();
1065
+ if (Array.isArray(agents) && agents.length > 0) {
1066
+ const remotePub = agents[0].publicKey;
1067
+ if (remotePub === localPub) {
1068
+ console.log(` ✅ ${agentId}: key matches Flair`);
1069
+ }
1070
+ else {
1071
+ console.log(` ⚠️ ${agentId}: key MISMATCH — local key doesn't match Flair record`);
1072
+ }
1073
+ }
1074
+ else {
1075
+ console.log(` ⚠️ ${agentId}: not found in Flair`);
1076
+ }
1077
+ }
1078
+ }
1079
+ catch (err) {
1080
+ console.log(` ⚠️ ${agentId}: verification failed — ${err.message}`);
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+ });
1087
+ await program.parseAsync();