@tpsdev-ai/flair 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ 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
9
  import { keystore } from "./keystore.js";
10
+ import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
10
11
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
11
12
  // src/ into resources/, which don't survive npm packaging (see also
12
13
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -253,7 +254,7 @@ async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
253
254
  });
254
255
  }
255
256
  async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
256
- const url = `http://127.0.0.1:${httpPort}/health`;
257
+ const url = `http://127.0.0.1:${httpPort}/Health`;
257
258
  const deadline = Date.now() + timeoutMs;
258
259
  let attempt = 0;
259
260
  while (Date.now() < deadline) {
@@ -263,7 +264,9 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
263
264
  headers: { Authorization: `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}` },
264
265
  signal: AbortSignal.timeout(2000),
265
266
  });
266
- if (res.status > 0)
267
+ // 2xx = healthy; 401 = Harper up but credentials wrong — still "reachable"
268
+ // enough for restart success. Anything else (5xx, 502 during shutdown) keeps polling.
269
+ if (res.ok || res.status === 401)
267
270
  return;
268
271
  }
269
272
  catch { /* not ready yet */ }
@@ -271,6 +274,35 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
271
274
  }
272
275
  throw new Error(`Harper at port ${httpPort} did not respond within ${timeoutMs}ms (${attempt} attempts)`);
273
276
  }
277
+ // Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
278
+ // Used during restart to confirm the old Harper process actually exited before
279
+ // we start polling /Health — otherwise the still-shutting-down old process can
280
+ // answer and we'd declare restart success while a gap is still ahead.
281
+ async function waitForProcessExit(pid, timeoutMs) {
282
+ const deadline = Date.now() + timeoutMs;
283
+ while (Date.now() < deadline) {
284
+ try {
285
+ process.kill(pid, 0);
286
+ }
287
+ catch {
288
+ return;
289
+ }
290
+ await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));
291
+ }
292
+ throw new Error(`Process ${pid} did not exit within ${timeoutMs}ms`);
293
+ }
294
+ function readHarperPid(dataDir) {
295
+ const pidFile = join(dataDir, "hdb.pid");
296
+ if (!existsSync(pidFile))
297
+ return null;
298
+ try {
299
+ const n = Number(readFileSync(pidFile, "utf-8").trim());
300
+ return Number.isInteger(n) && n > 0 ? n : null;
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ }
274
306
  async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass) {
275
307
  const url = `http://127.0.0.1:${opsPort}/`;
276
308
  const auth = Buffer.from(`${adminUser}:${adminPass}`).toString("base64");
@@ -292,6 +324,163 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
292
324
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
293
325
  }
294
326
  }
327
+ export function templateSoul(choice) {
328
+ const templates = {
329
+ "1": [
330
+ ["role", "Pair programmer on this machine. Concise, direct, proactive about flagging risks before I hit them."],
331
+ ["project", "(fill in: the main project or repo I'm helping with — shapes what bootstrap prioritizes)"],
332
+ ["standards", "Match existing codebase style. Prefer editing over rewriting. Surface tradeoffs on ambiguous decisions instead of making unilateral calls."],
333
+ ],
334
+ "2": [
335
+ ["role", "Team agent — operates in a shared repo and coordinates with other agents. Communicate through structured channels (PRs, issues, mail), not free-form chat."],
336
+ ["project", "(fill in: the repo or ops flow this agent runs in)"],
337
+ ["standards", "Keep changes minimal and reviewable. Always open PRs, never push to main. Document decisions in the issue tracker, not in agent memory."],
338
+ ],
339
+ "3": [
340
+ ["role", "Research assistant. Survey sources, extract findings, write structured notes. Flag uncertainty explicitly; separate evidence from inference."],
341
+ ["project", "(fill in: the research area or question being tracked)"],
342
+ ["standards", "Cite sources inline. When sources disagree, surface the disagreement rather than picking a side silently. Prefer primary sources."],
343
+ ],
344
+ };
345
+ return templates[choice] ?? [];
346
+ }
347
+ async function customSoulPrompts(ask) {
348
+ const entries = [];
349
+ console.log("\n Three fields. Press Enter on any to skip it.\n");
350
+ console.log(" role — how the agent identifies itself and acts");
351
+ console.log(" \"Senior dev, concise and direct\"");
352
+ console.log(" \"Data-engineering sidekick, SQL-first\"");
353
+ console.log(" \"PM assistant — asks clarifying questions before writing specs\"");
354
+ const role = await ask(" > ");
355
+ if (role.trim())
356
+ entries.push(["role", role.trim()]);
357
+ console.log("\n project — what the agent is currently focused on");
358
+ console.log(" \"LifestyleLab — building Flair and TPS\"");
359
+ console.log(" \"Legal discovery review, Q2 contracts\"");
360
+ console.log(" \"Personal automation scripts in Bash + Python\"");
361
+ const project = await ask(" > ");
362
+ if (project.trim())
363
+ entries.push(["project", project.trim()]);
364
+ console.log("\n standards — communication or coding preferences that should persist");
365
+ console.log(" \"No emojis. Match existing style. Ask before risky ops.\"");
366
+ console.log(" \"Always cite sources. Flag uncertainty explicitly.\"");
367
+ console.log(" \"Typescript strict mode. Prefer composition over inheritance.\"");
368
+ const standards = await ask(" > ");
369
+ if (standards.trim())
370
+ entries.push(["standards", standards.trim()]);
371
+ return entries;
372
+ }
373
+ async function editEntries(ask, entries) {
374
+ console.log("\n Press Enter to keep each default, or type a replacement:");
375
+ const result = [];
376
+ for (const [key, def] of entries) {
377
+ const preview = def.length > 60 ? def.slice(0, 57) + "..." : def;
378
+ console.log(`\n ${key} [keep: ${preview}]`);
379
+ const input = (await ask(" > ")).trim();
380
+ result.push([key, input || def]);
381
+ }
382
+ return result;
383
+ }
384
+ export function parseSoulJson(raw) {
385
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
386
+ if (!jsonMatch)
387
+ throw new Error("no JSON object found in input");
388
+ const parsed = JSON.parse(jsonMatch[0]);
389
+ const entries = [];
390
+ if (parsed.role)
391
+ entries.push(["role", String(parsed.role).trim()]);
392
+ if (parsed.project)
393
+ entries.push(["project", String(parsed.project).trim()]);
394
+ if (parsed.standards)
395
+ entries.push(["standards", String(parsed.standards).trim()]);
396
+ if (entries.length === 0)
397
+ throw new Error("JSON had no role/project/standards keys");
398
+ return entries;
399
+ }
400
+ async function runSoulWizard(agentId) {
401
+ const { createInterface } = await import("node:readline");
402
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
403
+ // Buffered ask: collects rapid input (pasted text) into one answer.
404
+ // Waits 200ms after last line before resolving, so pasted multiline
405
+ // blocks are captured as a single answer instead of spilling across prompts.
406
+ const ask = (q) => new Promise(resolve => {
407
+ let buffer = "";
408
+ let timer = null;
409
+ let done = false;
410
+ const finish = () => {
411
+ if (done)
412
+ return;
413
+ done = true;
414
+ rl.removeListener("line", onLine);
415
+ resolve(buffer.trim());
416
+ };
417
+ const onLine = (line) => {
418
+ buffer += (buffer ? "\n" : "") + line;
419
+ if (timer)
420
+ clearTimeout(timer);
421
+ timer = setTimeout(finish, 200);
422
+ };
423
+ process.stdout.write(q);
424
+ rl.on("line", onLine);
425
+ });
426
+ console.log("\n🎭 Agent personality setup");
427
+ console.log(" Soul entries shape what every future session starts with.\n");
428
+ console.log(" What best describes this agent?");
429
+ console.log(" (1) Solo developer — helps you with code on this machine");
430
+ console.log(" (2) Team agent — runs in a shared repo / ops flow");
431
+ console.log(" (3) Research assistant — surveys sources, writes notes");
432
+ console.log(" (4) Draft from Claude — paste a Claude-generated JSON draft");
433
+ console.log(" (5) Custom — I'll prompt for each field with examples");
434
+ console.log(" (s) Skip — set up later; `flair doctor` will nudge\n");
435
+ const choice = (await ask(" Choice [1-5/s]: ")).trim().toLowerCase();
436
+ let entries = [];
437
+ if (choice === "s" || choice === "skip") {
438
+ rl.close();
439
+ return [];
440
+ }
441
+ else if (choice === "1" || choice === "2" || choice === "3") {
442
+ entries = templateSoul(choice);
443
+ console.log("\n Template draft:");
444
+ for (const [k, v] of entries)
445
+ console.log(` ${k}: ${v}`);
446
+ const edit = (await ask("\n Edit before saving? [y/N]: ")).trim().toLowerCase();
447
+ if (edit === "y" || edit === "yes") {
448
+ entries = await editEntries(ask, entries);
449
+ }
450
+ }
451
+ else if (choice === "4") {
452
+ console.log("\n Paste this prompt into your Claude session:");
453
+ console.log(" ─────────────────────────────────────────────────────────────");
454
+ console.log(` Generate a JSON object with keys "role", "project", and`);
455
+ console.log(` "standards" suitable as Flair soul entries for an agent with`);
456
+ console.log(` id "${agentId}" operating in my current context. Each value`);
457
+ console.log(` should be 1-2 specific sentences that shape behavior. Output`);
458
+ console.log(` only the JSON object, no prose.`);
459
+ console.log(" ─────────────────────────────────────────────────────────────\n");
460
+ console.log(" Paste the resulting JSON below:");
461
+ const raw = await ask(" > ");
462
+ try {
463
+ entries = parseSoulJson(raw);
464
+ console.log("\n Parsed draft:");
465
+ for (const [k, v] of entries)
466
+ console.log(` ${k}: ${v}`);
467
+ const edit = (await ask("\n Edit before saving? [y/N]: ")).trim().toLowerCase();
468
+ if (edit === "y" || edit === "yes") {
469
+ entries = await editEntries(ask, entries);
470
+ }
471
+ }
472
+ catch (err) {
473
+ console.log(`\n Couldn't parse JSON (${err.message}). Falling back to custom prompts.`);
474
+ entries = await customSoulPrompts(ask);
475
+ }
476
+ }
477
+ else {
478
+ // Custom (5) or unrecognized input — route to custom prompts
479
+ entries = await customSoulPrompts(ask);
480
+ }
481
+ rl.close();
482
+ return entries.filter(([, v]) => v.trim().length > 0);
483
+ }
295
484
  // ─── Program ─────────────────────────────────────────────────────────────────
296
485
  // Read version from package.json at the package root
297
486
  const __pkgDir = join(import.meta.dirname ?? __dirname, "..");
@@ -535,43 +724,15 @@ program
535
724
  }
536
725
  console.log(`\n Export: FLAIR_URL=${httpUrl}`);
537
726
  // ── First-run soul setup ──────────────────────────────────────────────
538
- // Interactive prompts to set initial personality. Skipped with --skip-soul
539
- // or when stdin is not a TTY (CI, scripts, piped input).
727
+ // Interactive wizard to set initial personality (see runSoulWizard).
728
+ // Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
729
+ //
730
+ // Non-TTY / --skip-soul used to seed placeholder text like
731
+ // "AI assistant [default]" — it leaked into bootstrap output and
732
+ // confused users. Now those paths leave the soul empty and nudge the
733
+ // user toward `flair soul set` / `flair doctor` instead.
540
734
  if (!opts.skipSoul && process.stdin.isTTY) {
541
- console.log("\n🎭 Set up agent personality (press Enter to skip any):\n");
542
- const { createInterface } = await import("node:readline");
543
- const rl = createInterface({ input: process.stdin, output: process.stdout });
544
- // Buffered ask: collects rapid input (pasted text) into one answer.
545
- // Waits 200ms after last line before resolving, so pasted multiline
546
- // blocks are captured as a single answer instead of spilling across prompts.
547
- const ask = (q) => new Promise(resolve => {
548
- let buffer = "";
549
- let timer = null;
550
- const finish = () => {
551
- rl.removeListener("line", onLine);
552
- resolve(buffer.trim());
553
- };
554
- const onLine = (line) => {
555
- buffer += (buffer ? "\n" : "") + line;
556
- if (timer)
557
- clearTimeout(timer);
558
- timer = setTimeout(finish, 200);
559
- };
560
- process.stdout.write(q);
561
- rl.on("line", onLine);
562
- });
563
- const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
564
- const project = await ask(" What project is it working on?\n > ");
565
- const standards = await ask(" Any coding standards or preferences?\n > ");
566
- rl.close();
567
- // Write non-empty answers as soul entries
568
- const soulEntries = [];
569
- if (role.trim())
570
- soulEntries.push(["role", role.trim()]);
571
- if (project.trim())
572
- soulEntries.push(["project", project.trim()]);
573
- if (standards.trim())
574
- soulEntries.push(["standards", standards.trim()]);
735
+ const soulEntries = await runSoulWizard(agentId);
575
736
  if (soulEntries.length > 0) {
576
737
  console.log("");
577
738
  for (const [key, value] of soulEntries) {
@@ -583,30 +744,19 @@ program
583
744
  console.warn(` ⚠ soul:${key} failed: ${err.message}`);
584
745
  }
585
746
  }
586
- console.log(`\n ${soulEntries.length} soul entries saved. Bootstrap will include them.`);
747
+ console.log(`\n ${soulEntries.length} soul entries saved.`);
748
+ console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
587
749
  }
588
750
  else {
589
- console.log("\n No soul entries you can add them later with: flair soul set --agent " + agentId + " --key role --value \"...\"");
751
+ console.log(`\n No soul entries saved. Add later with:`);
752
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
753
+ console.log(` Or run \`flair doctor\` anytime for a nudge.`);
590
754
  }
591
755
  }
592
756
  else {
593
- // --skip-soul or non-interactive: seed sensible defaults so bootstrap returns useful context
594
- const defaultSoulEntries = [
595
- ["role", "AI assistant [default customize with 'flair soul set']"],
596
- ["personality", "Helpful, precise, and proactive [default — customize with 'flair soul set']"],
597
- ["constraints", "Respect user privacy. Be concise. [default — customize with 'flair soul set']"],
598
- ];
599
- console.log("\nSeeding default soul entries...");
600
- for (const [key, value] of defaultSoulEntries) {
601
- try {
602
- await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
603
- console.log(` ✓ soul:${key} set (default)`);
604
- }
605
- catch (err) {
606
- console.warn(` ⚠ soul:${key} failed: ${err.message}`);
607
- }
608
- }
609
- console.log(` Customize with: flair soul set --agent ${agentId} --key role --value "..."`);
757
+ const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
758
+ console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
759
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
610
760
  }
611
761
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
612
762
  console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
@@ -1304,8 +1454,8 @@ program
1304
1454
  table: "MemoryGrant",
1305
1455
  records: [{
1306
1456
  id: grantId,
1307
- fromAgentId: fromAgent,
1308
- toAgentId: toAgent,
1457
+ ownerId: fromAgent,
1458
+ granteeId: toAgent,
1309
1459
  scope,
1310
1460
  createdAt: new Date().toISOString(),
1311
1461
  }],
@@ -1793,23 +1943,81 @@ rem
1793
1943
  }
1794
1944
  });
1795
1945
  // ─── flair status ─────────────────────────────────────────────────────────────
1796
- program
1797
- .command("status")
1798
- .description("Show Flair instance status, memory stats, and agent info")
1799
- .option("--port <port>", "Harper HTTP port")
1800
- .option("--url <url>", "Flair base URL (overrides --port)")
1801
- .option("--json", "Output as JSON")
1802
- .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
1803
- .action(async (opts) => {
1946
+ function humanBytes(n) {
1947
+ if (!Number.isFinite(n) || n < 0)
1948
+ return "—";
1949
+ if (n < 1024)
1950
+ return `${n} B`;
1951
+ if (n < 1024 * 1024)
1952
+ return `${(n / 1024).toFixed(1)} KB`;
1953
+ if (n < 1024 * 1024 * 1024)
1954
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
1955
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
1956
+ }
1957
+ function relativeTime(iso) {
1958
+ if (!iso)
1959
+ return "—";
1960
+ const ago = Date.now() - new Date(iso).getTime();
1961
+ if (!Number.isFinite(ago) || ago < 0)
1962
+ return "—";
1963
+ const mins = Math.floor(ago / 60000);
1964
+ const hrs = Math.floor(ago / 3600000);
1965
+ const days = Math.floor(ago / 86400000);
1966
+ return days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
1967
+ }
1968
+ // Renders OAuth status lines from non-secret metadata. /HealthDetail never
1969
+ // returns clientSecret — only counts and identifying fields (id, name,
1970
+ // registeredBy, createdAt, issuer). Inputs are coerced to scalar primitives
1971
+ // before formatting to keep the display values clearly separated from the
1972
+ // source record.
1973
+ function oauthSummaryLines(o) {
1974
+ const clients = Number(o?.clients ?? 0);
1975
+ const idps = Number(o?.idpConfigs ?? 0);
1976
+ const tokens = Number(o?.activeTokens ?? 0);
1977
+ return [
1978
+ "\nOAuth:",
1979
+ ` Clients: ${clients} IdPs: ${idps} Active tokens: ${tokens}`,
1980
+ ];
1981
+ }
1982
+ function oauthDetailLines(o) {
1983
+ const clients = Number(o?.clients ?? 0);
1984
+ const idps = Number(o?.idpConfigs ?? 0);
1985
+ const tokens = Number(o?.activeTokens ?? 0);
1986
+ const out = [
1987
+ "OAuth:",
1988
+ ` Clients: ${clients}`,
1989
+ ` IdP configs: ${idps}`,
1990
+ ` Active tokens: ${tokens}`,
1991
+ ];
1992
+ if (Array.isArray(o?.clientList) && o.clientList.length > 0) {
1993
+ out.push("", " Clients:");
1994
+ for (const c of o.clientList) {
1995
+ const id = String(c?.id ?? "");
1996
+ const name = String(c?.name ?? "—");
1997
+ const registeredBy = String(c?.registeredBy ?? "—");
1998
+ const createdAt = String(c?.createdAt ?? "—");
1999
+ out.push(` ${id} ${name} ${registeredBy} ${createdAt}`);
2000
+ }
2001
+ }
2002
+ if (Array.isArray(o?.idpList) && o.idpList.length > 0) {
2003
+ out.push("", " IdPs:");
2004
+ for (const i of o.idpList) {
2005
+ const id = String(i?.id ?? "");
2006
+ const name = String(i?.name ?? "—");
2007
+ const issuer = String(i?.issuer ?? "—");
2008
+ out.push(` ${id} ${name} ${issuer}`);
2009
+ }
2010
+ }
2011
+ return out;
2012
+ }
2013
+ async function fetchHealthDetail(opts) {
1804
2014
  const port = resolveHttpPort(opts);
1805
2015
  const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
1806
2016
  let healthy = false;
1807
2017
  let healthData = null;
1808
- // 1. Basic health check — try unauthenticated first, then with admin auth
1809
2018
  try {
1810
2019
  let res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1811
2020
  if (!res.ok && res.status === 401) {
1812
- // Harper requires auth (authorizeLocal: true) — retry with admin credentials
1813
2021
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
1814
2022
  if (adminPass) {
1815
2023
  res = await fetch(`${baseUrl}/Health`, {
@@ -1821,7 +2029,6 @@ program
1821
2029
  healthy = res.ok;
1822
2030
  }
1823
2031
  catch { /* unreachable */ }
1824
- // 2. Try authenticated /HealthDetail for rich stats
1825
2032
  if (healthy) {
1826
2033
  const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
1827
2034
  if (agentId) {
@@ -1833,14 +2040,12 @@ program
1833
2040
  headers: { Authorization: authHeader },
1834
2041
  signal: AbortSignal.timeout(5000),
1835
2042
  });
1836
- if (res.ok) {
2043
+ if (res.ok)
1837
2044
  healthData = await res.json().catch(() => null);
1838
- }
1839
2045
  }
1840
- catch { /* fall through to basic output */ }
2046
+ catch { /* fall through */ }
1841
2047
  }
1842
2048
  }
1843
- // Fallback: try admin basic auth if available
1844
2049
  if (!healthData) {
1845
2050
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
1846
2051
  if (adminPass) {
@@ -1850,14 +2055,24 @@ program
1850
2055
  headers: { Authorization: auth },
1851
2056
  signal: AbortSignal.timeout(5000),
1852
2057
  });
1853
- if (res.ok) {
2058
+ if (res.ok)
1854
2059
  healthData = await res.json().catch(() => null);
1855
- }
1856
2060
  }
1857
- catch { /* fall through to basic output */ }
2061
+ catch { /* fall through */ }
1858
2062
  }
1859
2063
  }
1860
2064
  }
2065
+ return { healthy, baseUrl, healthData };
2066
+ }
2067
+ const statusCmd = program
2068
+ .command("status")
2069
+ .description("Show Flair instance status, memory stats, and agent info")
2070
+ .option("--port <port>", "Harper HTTP port")
2071
+ .option("--url <url>", "Flair base URL (overrides --port)")
2072
+ .option("--json", "Output as JSON")
2073
+ .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
2074
+ .action(async (opts) => {
2075
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
1861
2076
  if (opts.json) {
1862
2077
  console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
1863
2078
  if (!healthy)
@@ -1870,7 +2085,6 @@ program
1870
2085
  console.log(`\n Run: flair start or flair doctor`);
1871
2086
  process.exit(1);
1872
2087
  }
1873
- // Format uptime
1874
2088
  const uptimeSec = healthData?.uptimeSeconds;
1875
2089
  let uptimeStr = "";
1876
2090
  if (uptimeSec != null) {
@@ -1879,41 +2093,268 @@ program
1879
2093
  const m = Math.floor((uptimeSec % 3600) / 60);
1880
2094
  uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
1881
2095
  }
1882
- // Format last write as relative time
1883
- let lastWriteStr = "";
1884
- if (healthData?.lastWrite) {
1885
- const ago = Date.now() - new Date(healthData.lastWrite).getTime();
1886
- const mins = Math.floor(ago / 60000);
1887
- const hrs = Math.floor(ago / 3600000);
1888
- const days = Math.floor(ago / 86400000);
1889
- lastWriteStr = days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
1890
- }
1891
2096
  const pid = healthData?.pid ?? "";
1892
2097
  const agents = healthData?.agents;
1893
2098
  const memories = healthData?.memories;
1894
- console.log(`Flair v${__pkgVersion} 🟢 running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
2099
+ const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
2100
+ const hasWarn = warnings.some((w) => w.level === "warn");
2101
+ // Header state-word stays "running" whenever the process is alive; the
2102
+ // icon conveys health (🟢 clean / 🟡 warnings). 🔴 unreachable is already
2103
+ // handled above by the `!healthy` early-exit. Decoupling state from
2104
+ // health keeps the smoke-test `grep -q "running"` stable across tiers.
2105
+ const headerIcon = hasWarn ? "🟡" : "🟢";
2106
+ console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
1895
2107
  console.log(` URL: ${baseUrl}`);
2108
+ if (warnings.length > 0) {
2109
+ console.log(`\n⚠ Warnings: ${warnings.length}`);
2110
+ for (const w of warnings)
2111
+ console.log(` • ${w.level} ${w.message}`);
2112
+ }
1896
2113
  if (memories) {
1897
- const embStr = memories.withEmbeddings > 0
1898
- ? `${memories.withEmbeddings} with embeddings`
1899
- : "";
1900
- const hashStr = memories.hashFallback > 0
1901
- ? `${memories.hashFallback} hash-fallback`
1902
- : "";
2114
+ console.log("\nMemory:");
2115
+ const embStr = memories.withEmbeddings > 0 ? `${memories.withEmbeddings} embedded` : "";
2116
+ const hashStr = memories.hashFallback > 0 ? `${memories.hashFallback} hash` : "";
1903
2117
  const detail = [embStr, hashStr].filter(Boolean).join(", ");
1904
- console.log(` Memories: ${memories.total}${detail ? ` (${detail})` : ""}`);
2118
+ console.log(` Total: ${memories.total}${detail ? ` (${detail})` : ""}`);
2119
+ if (memories.modelCounts && typeof memories.modelCounts === "object") {
2120
+ const entries = Object.entries(memories.modelCounts)
2121
+ .filter(([, n]) => n > 0)
2122
+ .sort((a, b) => b[1] - a[1]);
2123
+ if (entries.length > 0) {
2124
+ const formatted = entries.map(([k, n]) => `${k}: ${n}`).join(", ");
2125
+ console.log(` Embeddings: ${formatted}`);
2126
+ }
2127
+ }
2128
+ if (memories.byDurability) {
2129
+ const d = memories.byDurability;
2130
+ console.log(` Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
2131
+ }
2132
+ if (typeof memories.archived === "number")
2133
+ console.log(` Archived: ${memories.archived}`);
2134
+ if (typeof memories.expired === "number" && memories.expired > 0)
2135
+ console.log(` Expired: ${memories.expired}`);
2136
+ if (healthData?.lastWrite)
2137
+ console.log(` Last write: ${relativeTime(healthData.lastWrite)}`);
2138
+ }
2139
+ if (agents && agents.count > 0) {
2140
+ console.log("\nAgents:");
2141
+ const nameStr = agents.names?.length > 0 ? ` — ${agents.names.join(", ")}` : "";
2142
+ console.log(` ${agents.count} total${nameStr}`);
2143
+ if (agents.count > 1 && Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
2144
+ const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
2145
+ // Older HealthDetail responses only carry id / memoryCount / lastWriteAt.
2146
+ // Only print the richer columns if at least one row supplies them.
2147
+ const hasDeep = agents.perAgent.some((r) => typeof r.hashFallback === "number" || typeof r.writes24h === "number");
2148
+ if (hasDeep) {
2149
+ console.log(` ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
2150
+ for (const r of agents.perAgent) {
2151
+ const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "—";
2152
+ const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
2153
+ console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
2154
+ }
2155
+ }
2156
+ else {
2157
+ console.log(` ${"id".padEnd(idW)} memories last_write`);
2158
+ for (const r of agents.perAgent) {
2159
+ console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${relativeTime(r.lastWriteAt)}`);
2160
+ }
2161
+ }
2162
+ }
2163
+ }
2164
+ if (healthData?.relationships) {
2165
+ const r = healthData.relationships;
2166
+ console.log("\nRelationships:");
2167
+ console.log(` ${r.total} total (${r.active} active)`);
2168
+ }
2169
+ if (healthData?.soul && healthData.soul.total > 0) {
2170
+ const s = healthData.soul;
2171
+ const bp = s.byPriority ?? {};
2172
+ console.log("\nSoul:");
2173
+ console.log(` ${s.total} entries — ${bp.critical ?? 0} critical / ${bp.high ?? 0} high / ${bp.standard ?? 0} standard / ${bp.low ?? 0} low`);
2174
+ }
2175
+ else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
2176
+ console.log("\nSoul:");
2177
+ console.log(` ${healthData.soulEntries} entries`);
2178
+ }
2179
+ if (healthData?.rem) {
2180
+ const r = healthData.rem;
2181
+ console.log("\nREM:");
2182
+ if (r.lastLightAt)
2183
+ console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
2184
+ if (r.lastRapidAt)
2185
+ console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
2186
+ if (r.lastRestorativeAt)
2187
+ console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
2188
+ const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
2189
+ console.log(` Nightly: ${nightly}`);
2190
+ if (r.nightlyEnabled && r.lastNightlyAt)
2191
+ console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)}`);
2192
+ if (typeof r.pendingCandidates === "number" && r.pendingCandidates > 0) {
2193
+ console.log(` Pending candidates: ${r.pendingCandidates}`);
2194
+ }
2195
+ }
2196
+ if (healthData?.federation) {
2197
+ const f = healthData.federation;
2198
+ console.log("\nFederation:");
2199
+ if (f.instance)
2200
+ console.log(` Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
2201
+ if (f.peers)
2202
+ console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
2203
+ if (f.pendingTokens > 0)
2204
+ console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
2205
+ }
2206
+ if (healthData?.oauth) {
2207
+ const lines = oauthSummaryLines(healthData.oauth);
2208
+ for (const line of lines)
2209
+ console.log(line);
2210
+ }
2211
+ if (healthData?.bridges) {
2212
+ const b = healthData.bridges;
2213
+ console.log("\nBridges:");
2214
+ if (Array.isArray(b.installed) && b.installed.length > 0)
2215
+ console.log(` Installed: ${b.installed.join(", ")}`);
2216
+ if (b.lastImport)
2217
+ console.log(` Last import: ${relativeTime(b.lastImport)}`);
2218
+ if (b.lastExport)
2219
+ console.log(` Last export: ${relativeTime(b.lastExport)}`);
2220
+ }
2221
+ if (healthData?.disk) {
2222
+ const d = healthData.disk;
2223
+ console.log("\nDisk:");
2224
+ console.log(` Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
2225
+ console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
2226
+ }
2227
+ console.log("");
2228
+ if (warnings.length > 0)
2229
+ console.log(` Health: ⚠ ${warnings.length} warning(s)`);
2230
+ else
2231
+ console.log(` Health: ✅ all checks passing`);
2232
+ });
2233
+ statusCmd
2234
+ .command("rem")
2235
+ .description("Show REM (memory hygiene) subsystem status")
2236
+ .action(async function () {
2237
+ const opts = this.optsWithGlobals();
2238
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2239
+ if (opts.json) {
2240
+ console.log(JSON.stringify({ healthy, rem: healthData?.rem ?? null }, null, 2));
2241
+ if (!healthy)
2242
+ process.exit(1);
2243
+ return;
1905
2244
  }
1906
- if (agents) {
1907
- const nameStr = agents.names?.length > 0 ? ` (${agents.names.join(", ")})` : "";
1908
- console.log(` Agents: ${agents.count}${nameStr}`);
2245
+ if (!healthy) {
2246
+ console.log("🔴 unreachable");
2247
+ process.exit(1);
1909
2248
  }
1910
- if (healthData?.soulEntries != null) {
1911
- console.log(` Soul: ${healthData.soulEntries} entries`);
2249
+ const r = healthData?.rem;
2250
+ if (!r) {
2251
+ console.log("REM: not configured (no log entries or platform timers found)");
2252
+ return;
1912
2253
  }
1913
- if (lastWriteStr) {
1914
- console.log(` Last write: ${lastWriteStr}`);
2254
+ console.log("REM:");
2255
+ console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
2256
+ console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
2257
+ console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
2258
+ const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
2259
+ console.log(` Nightly: ${nightly}`);
2260
+ if (r.lastNightlyAt)
2261
+ console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
2262
+ if (typeof r.pendingCandidates === "number")
2263
+ console.log(` Pending candidates: ${r.pendingCandidates}`);
2264
+ else
2265
+ console.log(` Pending candidates: — (schema not available)`);
2266
+ });
2267
+ statusCmd
2268
+ .command("federation")
2269
+ .description("Show federation subsystem status")
2270
+ .action(async function () {
2271
+ const opts = this.optsWithGlobals();
2272
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2273
+ if (opts.json) {
2274
+ console.log(JSON.stringify({ healthy, federation: healthData?.federation ?? null }, null, 2));
2275
+ if (!healthy)
2276
+ process.exit(1);
2277
+ return;
2278
+ }
2279
+ if (!healthy) {
2280
+ console.log("🔴 unreachable");
2281
+ process.exit(1);
2282
+ }
2283
+ const f = healthData?.federation;
2284
+ if (!f) {
2285
+ console.log("Federation: not configured");
2286
+ return;
2287
+ }
2288
+ console.log("Federation:");
2289
+ if (f.instance)
2290
+ console.log(` Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
2291
+ else
2292
+ console.log(" Instance: —");
2293
+ if (f.peers)
2294
+ console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
2295
+ if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
2296
+ console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
2297
+ if (Array.isArray(f.peerList) && f.peerList.length > 0) {
2298
+ const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
2299
+ console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
2300
+ for (const p of f.peerList) {
2301
+ console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "—").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
2302
+ }
1915
2303
  }
1916
- console.log(` Health: ✅ all checks passing`);
2304
+ });
2305
+ statusCmd
2306
+ .command("auth")
2307
+ .description("Show OAuth / IdP subsystem status")
2308
+ .action(async function () {
2309
+ const opts = this.optsWithGlobals();
2310
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2311
+ if (opts.json) {
2312
+ console.log(JSON.stringify({ healthy, oauth: healthData?.oauth ?? null }, null, 2));
2313
+ if (!healthy)
2314
+ process.exit(1);
2315
+ return;
2316
+ }
2317
+ if (!healthy) {
2318
+ console.log("🔴 unreachable");
2319
+ process.exit(1);
2320
+ }
2321
+ const o = healthData?.oauth;
2322
+ if (!o) {
2323
+ console.log("OAuth: not configured");
2324
+ return;
2325
+ }
2326
+ const lines = oauthDetailLines(o);
2327
+ for (const line of lines)
2328
+ console.log(line);
2329
+ });
2330
+ statusCmd
2331
+ .command("bridges")
2332
+ .description("Show memory bridges subsystem status")
2333
+ .action(async function () {
2334
+ const opts = this.optsWithGlobals();
2335
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2336
+ if (opts.json) {
2337
+ console.log(JSON.stringify({ healthy, bridges: healthData?.bridges ?? null }, null, 2));
2338
+ if (!healthy)
2339
+ process.exit(1);
2340
+ return;
2341
+ }
2342
+ if (!healthy) {
2343
+ console.log("🔴 unreachable");
2344
+ process.exit(1);
2345
+ }
2346
+ const b = healthData?.bridges;
2347
+ if (!b) {
2348
+ console.log("Bridges: none installed (no flair-bridge-* packages found)");
2349
+ return;
2350
+ }
2351
+ console.log("Bridges:");
2352
+ if (Array.isArray(b.installed) && b.installed.length > 0)
2353
+ console.log(` Installed: ${b.installed.join(", ")}`);
2354
+ if (b.lastImport)
2355
+ console.log(` Last import: ${relativeTime(b.lastImport)}`);
2356
+ if (b.lastExport)
2357
+ console.log(` Last export: ${relativeTime(b.lastExport)}`);
1917
2358
  });
1918
2359
  // ─── flair upgrade ────────────────────────────────────────────────────────────
1919
2360
  program
@@ -2139,11 +2580,16 @@ program
2139
2580
  execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
2140
2581
  }
2141
2582
  catch { }
2142
- // Stop the service KeepAlive=true in the plist auto-restarts it
2583
+ // Capture the current PID *before* stopping so we can verify exit. Without
2584
+ // this, waitForHealth can race against the still-shutting-down old process
2585
+ // and return success before KeepAlive brings the new one up.
2586
+ const oldPid = readHarperPid(defaultDataDir());
2143
2587
  try {
2144
2588
  execSync(`launchctl stop ${label}`, { stdio: "pipe" });
2145
2589
  }
2146
2590
  catch { }
2591
+ if (oldPid)
2592
+ await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
2147
2593
  await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
2148
2594
  console.log("✅ Flair restarted");
2149
2595
  return;
@@ -2178,16 +2624,22 @@ program
2178
2624
  process.exit(1);
2179
2625
  }
2180
2626
  const dataDir = defaultDataDir();
2181
- const adminPass = process.env.HDB_ADMIN_PASSWORD ?? "";
2627
+ // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
2628
+ // Without this, `flair init --admin-pass X` (which only exports HDB_*
2629
+ // to the initial Harper spawn) followed by `flair restart` would silently
2630
+ // drop admin credentials — any subsequent auth'd call returns 401.
2631
+ const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
2182
2632
  const env = {
2183
2633
  ...process.env,
2184
2634
  ROOTPATH: dataDir,
2185
2635
  DEFAULTS_MODE: "dev",
2186
2636
  HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
2187
- HDB_ADMIN_PASSWORD: adminPass,
2188
2637
  HTTP_PORT: String(port),
2189
2638
  LOCAL_STUDIO: "false",
2190
2639
  };
2640
+ if (adminPass) {
2641
+ env.HDB_ADMIN_PASSWORD = adminPass;
2642
+ }
2191
2643
  const proc = spawn(process.execPath, [bin, "run", "."], {
2192
2644
  cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
2193
2645
  });
@@ -2398,19 +2850,21 @@ program
2398
2850
  failed++;
2399
2851
  }
2400
2852
  };
2401
- // 1. Write a test memory via POST /Memory
2402
- await check("Write test memory (POST /Memory)", async () => {
2853
+ // 1. Write a test memory via PUT /Memory/<id>.
2854
+ // Schema only exposes PUT — POST returns 'Memory does not have a post method implemented'.
2855
+ await check("Write test memory (PUT /Memory/<id>)", async () => {
2856
+ const id = `flair-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2403
2857
  const body = {
2858
+ id,
2404
2859
  content: "flair test \u2014 this will be deleted",
2405
2860
  durability: "ephemeral",
2406
2861
  createdAt: new Date().toISOString(),
2407
2862
  };
2408
2863
  if (agentId)
2409
2864
  body.agentId = agentId;
2410
- const result = await api("POST", "/Memory", body);
2411
- // id may be returned directly or nested
2412
- memoryId = result?.id ?? result?.[0]?.id ?? null;
2413
- return !!memoryId || result?.ok === true;
2865
+ await api("PUT", `/Memory/${id}`, body);
2866
+ memoryId = id;
2867
+ return true;
2414
2868
  });
2415
2869
  // 2. Search for the test memory via POST /SemanticSearch
2416
2870
  await check("Search for test memory (POST /SemanticSearch)", async () => {
@@ -2435,6 +2889,80 @@ program
2435
2889
  if (failed > 0)
2436
2890
  process.exit(1);
2437
2891
  });
2892
+ // ─── flair deploy ─────────────────────────────────────────────────────────────
2893
+ program
2894
+ .command("deploy")
2895
+ .description("Deploy Flair as a component to a remote Harper Fabric cluster")
2896
+ .option("--fabric-org <org>", "Fabric org (env: FABRIC_ORG)")
2897
+ .option("--fabric-cluster <cluster>", "Fabric cluster within the org (env: FABRIC_CLUSTER)")
2898
+ .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER)")
2899
+ .option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD)")
2900
+ .option("--fabric-token <token>", "OAuth bearer token (env: FABRIC_TOKEN) — reserved for future Fabric bearer support")
2901
+ .option("--target <url>", "Override the Fabric URL template (https://<cluster>.<org>.harperfabric.com)")
2902
+ .option("--project <name>", "Component name in Fabric", "flair")
2903
+ .option("--pkg-version <semver>", "Override version label (default: installed package version)")
2904
+ .option("--no-replicated", "Disable cluster-wide replication (default: replicated=true)")
2905
+ .option("--no-restart", "Do not restart the component after deploy (default: restart=true)")
2906
+ .option("--dry-run", "Resolve package, validate args, skip the deploy call")
2907
+ .option("--package-root <dir>", "Override package root (mainly for testing)")
2908
+ .action(async (opts) => {
2909
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
2910
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
2911
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
2912
+ const deployOpts = {
2913
+ fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
2914
+ fabricCluster: opts.fabricCluster ?? process.env.FABRIC_CLUSTER,
2915
+ fabricUser: opts.fabricUser ?? process.env.FABRIC_USER,
2916
+ fabricPassword: opts.fabricPassword ?? process.env.FABRIC_PASSWORD,
2917
+ fabricToken: opts.fabricToken ?? process.env.FABRIC_TOKEN,
2918
+ target: opts.target,
2919
+ project: opts.project,
2920
+ version: opts.pkgVersion,
2921
+ replicated: opts.replicated !== false,
2922
+ restart: opts.restart !== false,
2923
+ dryRun: opts.dryRun ?? false,
2924
+ packageRoot: opts.packageRoot,
2925
+ };
2926
+ const errors = validateDeployOptions(deployOpts);
2927
+ if (errors.length) {
2928
+ console.error(red("flair deploy: missing required options"));
2929
+ for (const e of errors)
2930
+ console.error(` - ${e}`);
2931
+ process.exit(1);
2932
+ }
2933
+ // Warn on password-via-flag (leaks to shell history). Env is preferred.
2934
+ if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
2935
+ console.error(dim("warning: --fabric-password leaks to shell history. " +
2936
+ "Prefer FABRIC_PASSWORD env."));
2937
+ }
2938
+ const url = buildDeployUrl(deployOpts);
2939
+ console.log(`${green("→")} Deploying ${deployOpts.project} to ${url}`);
2940
+ if (deployOpts.dryRun)
2941
+ console.log(dim(" (dry-run: skipping API call)"));
2942
+ try {
2943
+ const result = await deployToFabric(deployOpts);
2944
+ if (result.dryRun) {
2945
+ console.log(`${green("✓")} dry-run OK: ${result.project} ${result.version} ready to deploy to ${result.url}`);
2946
+ console.log(dim(` package root: ${result.packageRoot}`));
2947
+ return;
2948
+ }
2949
+ console.log(`\n${green("✓")} Flair ${result.version} deployed`);
2950
+ console.log(`\n URL: ${result.url}`);
2951
+ console.log(` Project: ${result.project}`);
2952
+ console.log(`\nNext steps:`);
2953
+ console.log(dim(` 1. Set an admin password in Fabric Studio (Cluster Settings → Admin)`));
2954
+ console.log(dim(` 2. Seed your first agent:`));
2955
+ console.log(` flair agent add --remote ${result.url} --name my-agent`);
2956
+ }
2957
+ catch (err) {
2958
+ console.error(red(`\n✗ deploy failed: ${err.message}`));
2959
+ const hint = err.message?.toLowerCase();
2960
+ if (hint?.includes("401") || hint?.includes("unauthoriz")) {
2961
+ console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
2962
+ }
2963
+ process.exit(1);
2964
+ }
2965
+ });
2438
2966
  // ─── flair doctor ─────────────────────────────────────────────────────────────
2439
2967
  program
2440
2968
  .command("doctor")
@@ -2688,23 +3216,81 @@ program
2688
3216
  });
2689
3217
  // ─── Memory and Soul commands ────────────────────────────────────────────────
2690
3218
  const memory = program.command("memory").description("Manage agent memories");
2691
- memory.command("add").requiredOption("--agent <id>").requiredOption("--content <text>")
3219
+ memory.command("add [content]").requiredOption("--agent <id>")
3220
+ .option("--content <text>", "memory content (alias for positional arg)")
2692
3221
  .option("--durability <d>", "standard").option("--tags <csv>")
2693
- .action(async (opts) => {
3222
+ .action(async (contentArg, opts) => {
3223
+ const content = contentArg ?? opts.content;
3224
+ if (!content) {
3225
+ console.error("error: content required (positional arg or --content)");
3226
+ process.exit(1);
3227
+ }
2694
3228
  const memId = `${opts.agent}-${Date.now()}`;
2695
3229
  const out = await api("PUT", `/Memory/${memId}`, {
2696
- id: memId, agentId: opts.agent, content: opts.content, durability: opts.durability || "standard",
3230
+ id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
2697
3231
  tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
2698
3232
  type: "memory", createdAt: new Date().toISOString(),
2699
3233
  });
2700
3234
  console.log(JSON.stringify(out, null, 2));
2701
3235
  });
2702
- memory.command("search").requiredOption("--agent <id>").requiredOption("--q <query>").option("--tag <tag>")
2703
- .action(async (opts) => console.log(JSON.stringify(await api("POST", "/SemanticSearch", { agentId: opts.agent, q: opts.q, tag: opts.tag }), null, 2)));
2704
- memory.command("list").requiredOption("--agent <id>").option("--tag <tag>")
3236
+ memory.command("search [query]").requiredOption("--agent <id>")
3237
+ .option("--q <query>", "search query (alias for positional arg)")
3238
+ .option("--limit <n>", "Max results", "5")
3239
+ .option("--tag <tag>")
3240
+ .action(async (queryArg, opts) => {
3241
+ const q = queryArg ?? opts.q;
3242
+ if (!q) {
3243
+ console.error("error: query required (positional arg or --q)");
3244
+ process.exit(1);
3245
+ }
3246
+ const body = { agentId: opts.agent, q, limit: parseInt(opts.limit, 10) || 5 };
3247
+ if (opts.tag)
3248
+ body.tag = opts.tag;
3249
+ console.log(JSON.stringify(await api("POST", "/SemanticSearch", body), null, 2));
3250
+ });
3251
+ memory.command("list")
3252
+ .requiredOption("--agent <id>")
3253
+ .option("--tag <tag>")
3254
+ .option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
3255
+ .option("--limit <n>", "Max rows when using --hash-fallback", "50")
2705
3256
  .action(async (opts) => {
2706
3257
  const q = new URLSearchParams({ agentId: opts.agent, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
2707
- console.log(JSON.stringify(await api("GET", `/Memory?${q}`), null, 2));
3258
+ const raw = await api("GET", `/Memory?${q}`);
3259
+ if (!opts.hashFallback) {
3260
+ console.log(JSON.stringify(raw, null, 2));
3261
+ return;
3262
+ }
3263
+ // --hash-fallback: filter to entries without a real embedding and print as a table.
3264
+ // Same predicate HealthDetail uses: missing model or the "hash-512d" marker.
3265
+ const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
3266
+ const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
3267
+ if (fallback.length === 0) {
3268
+ console.log(`No hash-fallback memories for agent ${opts.agent}. All embedded.`);
3269
+ return;
3270
+ }
3271
+ const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
3272
+ const rows = fallback
3273
+ .slice()
3274
+ .sort((a, b) => {
3275
+ const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
3276
+ const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
3277
+ return tb - ta;
3278
+ })
3279
+ .slice(0, limit);
3280
+ const idW = Math.max(2, ...rows.map((r) => String(r.id ?? "").length));
3281
+ console.log(`${fallback.length} hash-fallback memories for agent ${opts.agent} (showing ${rows.length}):\n`);
3282
+ console.log(` ${"id".padEnd(idW)} created_at preview`);
3283
+ for (const r of rows) {
3284
+ const created = r.createdAt ? String(r.createdAt).slice(0, 19).replace("T", " ") : "—".padEnd(19);
3285
+ const preview = String(r.content ?? "").replace(/\s+/g, " ").slice(0, 80);
3286
+ console.log(` ${String(r.id ?? "").padEnd(idW)} ${created} ${preview}`);
3287
+ }
3288
+ if (fallback.length > rows.length) {
3289
+ console.log(`\n... ${fallback.length - rows.length} more (raise with --limit). To backfill: flair reembed --agent ${opts.agent} --stale-only`);
3290
+ }
3291
+ else {
3292
+ console.log(`\nTo backfill: flair reembed --agent ${opts.agent} --stale-only`);
3293
+ }
2708
3294
  });
2709
3295
  // ─── flair search (top-level shortcut) ───────────────────────────────────────
2710
3296
  program
@@ -2801,6 +3387,320 @@ soul.command("set").requiredOption("--agent <id>").requiredOption("--key <key>")
2801
3387
  soul.command("get").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Soul/${id}`), null, 2)));
2802
3388
  soul.command("list").requiredOption("--agent <id>")
2803
3389
  .action(async (opts) => console.log(JSON.stringify(await api("GET", `/Soul?agentId=${encodeURIComponent(opts.agent)}`), null, 2)));
3390
+ // ─── flair bridge ────────────────────────────────────────────────────────────
3391
+ // Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
3392
+ // + agentic-stack reference adapter as a built-in.
3393
+ // `test` and `export` are still stubbed; Shape B (npm code plugins) too.
3394
+ // See specs/FLAIR-BRIDGES.md.
3395
+ const bridge = program.command("bridge").description("Manage memory bridges (import/export between Flair and foreign systems)");
3396
+ bridge
3397
+ .command("list")
3398
+ .description("List installed bridges across project YAML, user YAML, npm packages, and built-ins")
3399
+ .option("--json", "Output as JSON")
3400
+ .action(async (opts) => {
3401
+ const { discover } = await import("./bridges/discover.js");
3402
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3403
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3404
+ if (opts.json) {
3405
+ console.log(JSON.stringify(found, null, 2));
3406
+ return;
3407
+ }
3408
+ if (found.length === 0) {
3409
+ console.log("No bridges installed.");
3410
+ console.log("Add one with: flair bridge scaffold <name> --file");
3411
+ console.log("Or install from npm: npm install flair-bridge-<name>");
3412
+ return;
3413
+ }
3414
+ const nameW = Math.max(4, ...found.map((b) => b.name.length));
3415
+ const kindW = Math.max(4, ...found.map((b) => b.kind.length));
3416
+ const srcW = Math.max(6, ...found.map((b) => b.source.length));
3417
+ console.log(` ${"name".padEnd(nameW)} ${"kind".padEnd(kindW)} ${"source".padEnd(srcW)} description`);
3418
+ for (const b of found) {
3419
+ const desc = b.description ?? "";
3420
+ console.log(` ${b.name.padEnd(nameW)} ${b.kind.padEnd(kindW)} ${b.source.padEnd(srcW)} ${desc}`);
3421
+ }
3422
+ });
3423
+ bridge
3424
+ .command("scaffold <name>")
3425
+ .description("Emit starter files for a new bridge. Choose --file (YAML, declarative) or --api (TS code plugin)")
3426
+ .option("--file", "YAML file-format bridge (shape A)")
3427
+ .option("--api", "TypeScript API bridge (shape B)")
3428
+ .option("--force", "Overwrite existing files")
3429
+ .action(async (name, opts) => {
3430
+ if (opts.file && opts.api) {
3431
+ console.error("Pick one: --file or --api.");
3432
+ process.exit(1);
3433
+ }
3434
+ const { BUILTIN_BY_NAME } = await import("./bridges/builtins/index.js");
3435
+ if (BUILTIN_BY_NAME.has(name)) {
3436
+ console.error(`"${name}" is a built-in bridge name and can't be scaffolded — pick a different name.`);
3437
+ process.exit(1);
3438
+ }
3439
+ const kind = opts.api ? "api" : "file"; // --file is default
3440
+ const { scaffold } = await import("./bridges/scaffold.js");
3441
+ try {
3442
+ const result = await scaffold({ name, kind, force: !!opts.force });
3443
+ if (result.createdFiles.length > 0) {
3444
+ console.log(`Created ${result.createdFiles.length} file(s):`);
3445
+ for (const p of result.createdFiles)
3446
+ console.log(` + ${p}`);
3447
+ }
3448
+ if (result.skippedFiles.length > 0) {
3449
+ console.log(`Skipped ${result.skippedFiles.length} existing file(s) (pass --force to overwrite):`);
3450
+ for (const p of result.skippedFiles)
3451
+ console.log(` · ${p}`);
3452
+ }
3453
+ console.log(`\n${result.summary}`);
3454
+ }
3455
+ catch (err) {
3456
+ console.error(`Scaffold failed: ${err.message}`);
3457
+ process.exit(1);
3458
+ }
3459
+ });
3460
+ bridge
3461
+ .command("import <name> [src]")
3462
+ .description("Import memories from a foreign system into Flair via a bridge (Shape A YAML / built-in)")
3463
+ .option("--agent <id>", "Default agent ID for memories that don't carry one (or set FLAIR_AGENT_ID)")
3464
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative paths resolve against (default: cwd)")
3465
+ .option("--dry-run", "Validate + count, don't write to Flair")
3466
+ .option("--port <port>", "Harper HTTP port")
3467
+ .option("--url <url>", "Flair base URL (overrides --port)")
3468
+ .option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
3469
+ .action(async (name, srcArg, opts) => {
3470
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
3471
+ const cwd = opts.cwd ?? srcArg ?? process.cwd();
3472
+ const { discover } = await import("./bridges/discover.js");
3473
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3474
+ const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3475
+ const { runImport } = await import("./bridges/runtime/import-runner.js");
3476
+ const { makeContext } = await import("./bridges/runtime/context.js");
3477
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3478
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3479
+ const target = found.find((b) => b.name === name);
3480
+ if (!target) {
3481
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3482
+ process.exit(1);
3483
+ }
3484
+ let descriptor;
3485
+ try {
3486
+ descriptor = await loadDescriptor(target);
3487
+ }
3488
+ catch (err) {
3489
+ printBridgeError(err);
3490
+ process.exit(1);
3491
+ }
3492
+ const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3493
+ const ctx = makeContext({ bridge: name });
3494
+ // Memory POST: Ed25519-signed when an agent key is available, fall back
3495
+ // to the shared `api()` helper otherwise. Mirrors how `flair memory add`
3496
+ // works (see the `memory.command("add")` handler above).
3497
+ const putMemory = async (body) => {
3498
+ const headers = { "content-type": "application/json" };
3499
+ const keyPath = opts.key ?? resolveKeyPath(body.agentId);
3500
+ if (keyPath) {
3501
+ headers["authorization"] = buildEd25519Auth(body.agentId, "PUT", `/Memory/${body.id}`, keyPath);
3502
+ }
3503
+ const res = await fetch(`${baseUrl}/Memory/${encodeURIComponent(body.id)}`, {
3504
+ method: "PUT",
3505
+ headers,
3506
+ body: JSON.stringify(body),
3507
+ });
3508
+ if (!res.ok) {
3509
+ const text = await res.text().catch(() => "");
3510
+ throw new Error(`PUT /Memory/${body.id} → ${res.status}: ${text || res.statusText}`);
3511
+ }
3512
+ };
3513
+ let lastReportedAt = Date.now();
3514
+ let lastReportedOrdinal = 0;
3515
+ const onProgress = (ev) => {
3516
+ // Throttle in-progress chatter to at most one line every 2s + the
3517
+ // final summary. Avoids flooding stdout for big imports.
3518
+ if (ev.type === "done") {
3519
+ const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
3520
+ if (opts.dryRun) {
3521
+ console.log(`\n${descriptor.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
3522
+ }
3523
+ else {
3524
+ console.log(`\n${descriptor.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
3525
+ }
3526
+ return;
3527
+ }
3528
+ const now = Date.now();
3529
+ if (now - lastReportedAt < 2000 && ev.ordinal - lastReportedOrdinal < 25)
3530
+ return;
3531
+ lastReportedAt = now;
3532
+ lastReportedOrdinal = ev.ordinal;
3533
+ if (ev.type === "memory-imported") {
3534
+ process.stdout.write(`\r ${ev.ordinal} imported (${ev.foreignId ?? ev.flairId})`.padEnd(80));
3535
+ }
3536
+ else if (ev.type === "memory-skipped") {
3537
+ process.stdout.write(`\r ${ev.ordinal} skipped (${ev.reason})`.padEnd(80));
3538
+ }
3539
+ };
3540
+ try {
3541
+ await runImport({
3542
+ descriptor,
3543
+ cwd,
3544
+ agentId,
3545
+ dryRun: !!opts.dryRun,
3546
+ putMemory,
3547
+ onProgress,
3548
+ ctx,
3549
+ });
3550
+ }
3551
+ catch (err) {
3552
+ if (err instanceof BridgeRuntimeError) {
3553
+ printBridgeError(err);
3554
+ process.exit(1);
3555
+ }
3556
+ console.error(`Bridge import failed: ${err?.message ?? err}`);
3557
+ process.exit(1);
3558
+ }
3559
+ });
3560
+ bridge
3561
+ .command("export <name> <dst>")
3562
+ .description("Export memories from Flair to a foreign system via a bridge (Shape A YAML / built-in)")
3563
+ .requiredOption("--agent <id>", "Agent ID to export memories for (or set FLAIR_AGENT_ID)")
3564
+ .option("--source <tag>", "Filter to memories with a matching `source:` tag (typical for round-tripping a single bridge's data)")
3565
+ .option("--subject <subj>", "Filter to memories with a matching `subject:` tag")
3566
+ .option("--since <iso>", "Only memories with createdAt >= this ISO-8601 timestamp")
3567
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative target paths resolve against (default: cwd)")
3568
+ .option("--dry-run", "Validate + count + apply maps, don't write to the target")
3569
+ .option("--port <port>", "Harper HTTP port")
3570
+ .option("--url <url>", "Flair base URL (overrides --port)")
3571
+ .option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
3572
+ .action(async (name, dst, opts) => {
3573
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
3574
+ if (!agentId) {
3575
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
3576
+ process.exit(1);
3577
+ }
3578
+ const cwd = opts.cwd ?? dst;
3579
+ const { discover } = await import("./bridges/discover.js");
3580
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3581
+ const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3582
+ const { runExport } = await import("./bridges/runtime/export-runner.js");
3583
+ const { makeContext } = await import("./bridges/runtime/context.js");
3584
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3585
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3586
+ const target = found.find((b) => b.name === name);
3587
+ if (!target) {
3588
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3589
+ process.exit(1);
3590
+ }
3591
+ let descriptor;
3592
+ try {
3593
+ descriptor = await loadDescriptor(target);
3594
+ }
3595
+ catch (err) {
3596
+ printBridgeError(err);
3597
+ process.exit(1);
3598
+ }
3599
+ if (!descriptor.export) {
3600
+ console.error(`Bridge "${name}" has no export block — cannot export through it.`);
3601
+ process.exit(1);
3602
+ }
3603
+ const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3604
+ const ctx = makeContext({ bridge: name });
3605
+ // Memory fetcher — paginates GET /Memory?agentId=... applying any
3606
+ // descriptor + caller-side filters in memory. Slice 3a does the
3607
+ // simplest thing: one round trip, no streaming. Slice 3b can move
3608
+ // to cursor-paginated streaming if real corpora warrant it.
3609
+ const fetchMemories = async function* (filters) {
3610
+ const params = new URLSearchParams({ agentId });
3611
+ if (opts.subject)
3612
+ params.set("subject", opts.subject);
3613
+ const headers = { "content-type": "application/json" };
3614
+ const keyPath = opts.key ?? resolveKeyPath(agentId);
3615
+ const path = `/Memory?${params.toString()}`;
3616
+ if (keyPath)
3617
+ headers["authorization"] = buildEd25519Auth(agentId, "GET", path, keyPath);
3618
+ const res = await fetch(`${baseUrl}${path}`, { headers });
3619
+ if (!res.ok) {
3620
+ const text = await res.text().catch(() => "");
3621
+ throw new Error(`GET /Memory → ${res.status}: ${text || res.statusText}`);
3622
+ }
3623
+ const raw = await res.json();
3624
+ const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
3625
+ const sourceFilter = opts.source;
3626
+ const sinceMs = opts.since ? new Date(opts.since).getTime() : null;
3627
+ for (const m of all) {
3628
+ if (sourceFilter && m.source !== sourceFilter)
3629
+ continue;
3630
+ if (sinceMs !== null && m.createdAt && new Date(m.createdAt).getTime() < sinceMs)
3631
+ continue;
3632
+ yield m;
3633
+ }
3634
+ void filters;
3635
+ };
3636
+ let lastReportedAt = Date.now();
3637
+ const onProgress = (ev) => {
3638
+ if (ev.type === "done") {
3639
+ if (opts.dryRun) {
3640
+ console.log(`\n${descriptor.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
3641
+ }
3642
+ else {
3643
+ console.log(`\n${descriptor.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
3644
+ }
3645
+ return;
3646
+ }
3647
+ if (ev.type === "target-write") {
3648
+ console.log(` ✓ ${ev.path} (${ev.written} record${ev.written === 1 ? "" : "s"})`);
3649
+ return;
3650
+ }
3651
+ if (ev.type === "target-skipped") {
3652
+ console.log(` · ${ev.path} skipped (${ev.reason})`);
3653
+ return;
3654
+ }
3655
+ // memory-skipped events throttled to one line every 2s
3656
+ const now = Date.now();
3657
+ if (now - lastReportedAt < 2000)
3658
+ return;
3659
+ lastReportedAt = now;
3660
+ process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
3661
+ };
3662
+ try {
3663
+ await runExport({
3664
+ descriptor,
3665
+ cwd,
3666
+ fetchMemories,
3667
+ filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
3668
+ dryRun: !!opts.dryRun,
3669
+ ctx,
3670
+ onProgress,
3671
+ });
3672
+ }
3673
+ catch (err) {
3674
+ if (err instanceof BridgeRuntimeError) {
3675
+ printBridgeError(err);
3676
+ process.exit(1);
3677
+ }
3678
+ console.error(`Bridge export failed: ${err?.message ?? err}`);
3679
+ process.exit(1);
3680
+ }
3681
+ });
3682
+ // `test` still stubbed — round-trip harness lands in slice 3b.
3683
+ bridge
3684
+ .command("test <name> [args...]")
3685
+ .description("test a bridge — not yet implemented (slice 3b of FLAIR-BRIDGES)")
3686
+ .allowUnknownOption()
3687
+ .action(() => {
3688
+ console.error(`\`flair bridge test\` is not yet implemented — landing in slice 3b of FLAIR-BRIDGES.`);
3689
+ console.error(`Slice 3a ships export (Shape A YAML); the round-trip test harness pairs with it.`);
3690
+ process.exit(2);
3691
+ });
3692
+ function printBridgeError(err) {
3693
+ // Pretty-print BridgeRuntimeError as the structured shape from §10 of the
3694
+ // spec, plus a one-line human summary so the operator gets both.
3695
+ const detail = err?.detail;
3696
+ if (detail && typeof detail === "object") {
3697
+ console.error(`Bridge error: ${detail.hint ?? err.message}`);
3698
+ console.error(JSON.stringify(detail, null, 2));
3699
+ }
3700
+ else {
3701
+ console.error(`Bridge error: ${err.message ?? String(err)}`);
3702
+ }
3703
+ }
2804
3704
  // ─── flair backup ────────────────────────────────────────────────────────────
2805
3705
  program
2806
3706
  .command("backup")
@@ -3210,110 +4110,6 @@ program
3210
4110
  console.log(`Souls: ${(data.souls ?? []).length}`);
3211
4111
  }
3212
4112
  });
3213
- // ─── flair migrate-keys ───────────────────────────────────────────────────────
3214
- program
3215
- .command("migrate-keys")
3216
- .description("Migrate agent keys from old path (~/.tps/secrets/flair/) to ~/.flair/keys/")
3217
- .option("--from <dir>", "Old keys directory", join(homedir(), ".tps", "secrets", "flair"))
3218
- .option("--to <dir>", "New keys directory", defaultKeysDir())
3219
- .option("--dry-run", "Show what would be migrated without copying")
3220
- .option("--port <port>", "Harper HTTP port")
3221
- .option("--ops-port <port>", "Harper operations API port")
3222
- .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
3223
- .action(async (opts) => {
3224
- const fromDir = opts.from;
3225
- const toDir = opts.to;
3226
- const dryRun = opts.dryRun ?? false;
3227
- const opsPort = resolveOpsPort(opts);
3228
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
3229
- if (!existsSync(fromDir)) {
3230
- console.log(`Old keys directory not found: ${fromDir}`);
3231
- console.log("Nothing to migrate.");
3232
- process.exit(0);
3233
- }
3234
- // Discover legacy keys: <id>-priv.key pattern
3235
- const { readdirSync, unlinkSync } = await import("node:fs");
3236
- const files = readdirSync(fromDir);
3237
- const keyFiles = files.filter((f) => f.endsWith("-priv.key"));
3238
- if (keyFiles.length === 0) {
3239
- console.log(`No legacy key files found in ${fromDir}`);
3240
- process.exit(0);
3241
- }
3242
- console.log(`Found ${keyFiles.length} legacy key file(s) in ${fromDir}:`);
3243
- let migrated = 0;
3244
- let skipped = 0;
3245
- for (const file of keyFiles) {
3246
- const agentId = file.replace("-priv.key", "");
3247
- const srcPath = join(fromDir, file);
3248
- const destPath = join(toDir, `${agentId}.key`);
3249
- if (existsSync(destPath)) {
3250
- console.log(` skip: ${agentId} — already exists at ${destPath}`);
3251
- skipped++;
3252
- continue;
3253
- }
3254
- if (dryRun) {
3255
- console.log(` would migrate: ${srcPath} → ${destPath}`);
3256
- migrated++;
3257
- continue;
3258
- }
3259
- mkdirSync(toDir, { recursive: true });
3260
- const keyData = readFileSync(srcPath);
3261
- writeFileSync(destPath, keyData);
3262
- chmodSync(destPath, 0o600);
3263
- console.log(` migrated: ${agentId} → ${destPath}`);
3264
- migrated++;
3265
- }
3266
- if (dryRun) {
3267
- console.log(`\n🔍 Dry run: ${migrated} key(s) would be migrated, ${skipped} skipped`);
3268
- }
3269
- else {
3270
- console.log(`\n✅ Migration complete: ${migrated} migrated, ${skipped} skipped`);
3271
- if (migrated > 0) {
3272
- console.log(`\nOld keys preserved at ${fromDir} (delete manually when confirmed working).`);
3273
- // Optionally verify keys match Flair records
3274
- if (adminPass) {
3275
- console.log("\nVerifying migrated keys against Flair...");
3276
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
3277
- for (const file of keyFiles) {
3278
- const agentId = file.replace("-priv.key", "");
3279
- const destPath = join(toDir, `${agentId}.key`);
3280
- if (!existsSync(destPath))
3281
- continue;
3282
- try {
3283
- const keyB64 = readFileSync(destPath, "utf-8").trim();
3284
- const seed = Buffer.from(keyB64, "base64");
3285
- const kp = nacl.sign.keyPair.fromSeed(new Uint8Array(seed));
3286
- const localPub = b64url(kp.publicKey);
3287
- const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
3288
- method: "POST",
3289
- headers: { "Content-Type": "application/json", Authorization: auth },
3290
- body: JSON.stringify({ operation: "search_by_value", database: "flair", table: "Agent", search_attribute: "id", search_value: agentId, get_attributes: ["id", "publicKey"] }),
3291
- signal: AbortSignal.timeout(10_000),
3292
- });
3293
- if (res.ok) {
3294
- const agents = await res.json();
3295
- if (Array.isArray(agents) && agents.length > 0) {
3296
- const remotePub = agents[0].publicKey;
3297
- if (remotePub === localPub) {
3298
- console.log(` ✅ ${agentId}: key matches Flair`);
3299
- }
3300
- else {
3301
- console.log(` ⚠️ ${agentId}: key MISMATCH — local key doesn't match Flair record`);
3302
- }
3303
- }
3304
- else {
3305
- console.log(` ⚠️ ${agentId}: not found in Flair`);
3306
- }
3307
- }
3308
- }
3309
- catch (err) {
3310
- console.log(` ⚠️ ${agentId}: verification failed — ${err.message}`);
3311
- }
3312
- }
3313
- }
3314
- }
3315
- }
3316
- });
3317
4113
  // Run CLI only when this is the entry point (not when imported for testing)
3318
4114
  if (import.meta.main) {
3319
4115
  await program.parseAsync();