@tpsdev-ai/flair 0.5.6 → 0.6.1

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).
@@ -323,6 +324,208 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
323
324
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
324
325
  }
325
326
  }
327
+ // ─── Upgrade presence probes ──────────────────────────────────────────────────
328
+ //
329
+ // `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
330
+ // version. That assumed the default npm global prefix and failed (silently,
331
+ // reporting "not installed") for mise / fnm / nvm / volta users whose prefix
332
+ // lives elsewhere — including for the running flair binary itself, which is
333
+ // clearly installed somewhere. These probes locate the package regardless of
334
+ // install path.
335
+ export function probeBinVersion(execFileSync, bin) {
336
+ // Run the binary's --version via argv (no shell). PATH resolution still
337
+ // happens (so we find the binary wherever npm/mise/fnm installed it),
338
+ // but there's no shell-string to inject into. CodeQL-safe and simpler.
339
+ try {
340
+ const out = execFileSync(bin, ["--version"], {
341
+ encoding: "utf-8",
342
+ timeout: 5000,
343
+ stdio: ["ignore", "pipe", "ignore"],
344
+ }).trim();
345
+ if (!out)
346
+ return null;
347
+ // Accept either "0.6.0" on its own or a line containing a semver.
348
+ const m = out.match(/\b(\d+\.\d+\.\d+(?:[\d.a-z.-]*)?)\b/);
349
+ return m ? m[1] : null;
350
+ }
351
+ catch {
352
+ return null;
353
+ }
354
+ }
355
+ export function probeLibVersion(pkgName) {
356
+ // Resolve the package's package.json from the running flair's module graph.
357
+ // If the lib is installed anywhere Node can see (including bundled as a
358
+ // dep of flair itself, sibling global install, or linked workspace), this
359
+ // finds it. If it's truly missing, require.resolve throws → null.
360
+ try {
361
+ const { createRequire } = require("node:module");
362
+ const req = createRequire(import.meta.url);
363
+ const pkgJsonPath = req.resolve(`${pkgName}/package.json`);
364
+ const { readFileSync } = require("node:fs");
365
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
366
+ return typeof pkg.version === "string" ? pkg.version : null;
367
+ }
368
+ catch {
369
+ return null;
370
+ }
371
+ }
372
+ export function templateSoul(choice) {
373
+ const templates = {
374
+ "1": [
375
+ ["role", "Pair programmer on this machine. Concise, direct, proactive about flagging risks before I hit them."],
376
+ ["project", "(fill in: the main project or repo I'm helping with — shapes what bootstrap prioritizes)"],
377
+ ["standards", "Match existing codebase style. Prefer editing over rewriting. Surface tradeoffs on ambiguous decisions instead of making unilateral calls."],
378
+ ],
379
+ "2": [
380
+ ["role", "Team agent — operates in a shared repo and coordinates with other agents. Communicate through structured channels (PRs, issues, mail), not free-form chat."],
381
+ ["project", "(fill in: the repo or ops flow this agent runs in)"],
382
+ ["standards", "Keep changes minimal and reviewable. Always open PRs, never push to main. Document decisions in the issue tracker, not in agent memory."],
383
+ ],
384
+ "3": [
385
+ ["role", "Research assistant. Survey sources, extract findings, write structured notes. Flag uncertainty explicitly; separate evidence from inference."],
386
+ ["project", "(fill in: the research area or question being tracked)"],
387
+ ["standards", "Cite sources inline. When sources disagree, surface the disagreement rather than picking a side silently. Prefer primary sources."],
388
+ ],
389
+ };
390
+ return templates[choice] ?? [];
391
+ }
392
+ async function customSoulPrompts(ask) {
393
+ const entries = [];
394
+ console.log("\n Three fields. Press Enter on any to skip it.\n");
395
+ console.log(" role — how the agent identifies itself and acts");
396
+ console.log(" \"Senior dev, concise and direct\"");
397
+ console.log(" \"Data-engineering sidekick, SQL-first\"");
398
+ console.log(" \"PM assistant — asks clarifying questions before writing specs\"");
399
+ const role = await ask(" > ");
400
+ if (role.trim())
401
+ entries.push(["role", role.trim()]);
402
+ console.log("\n project — what the agent is currently focused on");
403
+ console.log(" \"LifestyleLab — building Flair and TPS\"");
404
+ console.log(" \"Legal discovery review, Q2 contracts\"");
405
+ console.log(" \"Personal automation scripts in Bash + Python\"");
406
+ const project = await ask(" > ");
407
+ if (project.trim())
408
+ entries.push(["project", project.trim()]);
409
+ console.log("\n standards — communication or coding preferences that should persist");
410
+ console.log(" \"No emojis. Match existing style. Ask before risky ops.\"");
411
+ console.log(" \"Always cite sources. Flag uncertainty explicitly.\"");
412
+ console.log(" \"Typescript strict mode. Prefer composition over inheritance.\"");
413
+ const standards = await ask(" > ");
414
+ if (standards.trim())
415
+ entries.push(["standards", standards.trim()]);
416
+ return entries;
417
+ }
418
+ async function editEntries(ask, entries) {
419
+ console.log("\n Press Enter to keep each default, or type a replacement:");
420
+ const result = [];
421
+ for (const [key, def] of entries) {
422
+ const preview = def.length > 60 ? def.slice(0, 57) + "..." : def;
423
+ console.log(`\n ${key} [keep: ${preview}]`);
424
+ const input = (await ask(" > ")).trim();
425
+ result.push([key, input || def]);
426
+ }
427
+ return result;
428
+ }
429
+ export function parseSoulJson(raw) {
430
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
431
+ if (!jsonMatch)
432
+ throw new Error("no JSON object found in input");
433
+ const parsed = JSON.parse(jsonMatch[0]);
434
+ const entries = [];
435
+ if (parsed.role)
436
+ entries.push(["role", String(parsed.role).trim()]);
437
+ if (parsed.project)
438
+ entries.push(["project", String(parsed.project).trim()]);
439
+ if (parsed.standards)
440
+ entries.push(["standards", String(parsed.standards).trim()]);
441
+ if (entries.length === 0)
442
+ throw new Error("JSON had no role/project/standards keys");
443
+ return entries;
444
+ }
445
+ async function runSoulWizard(agentId) {
446
+ const { createInterface } = await import("node:readline");
447
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
448
+ // Buffered ask: collects rapid input (pasted text) into one answer.
449
+ // Waits 200ms after last line before resolving, so pasted multiline
450
+ // blocks are captured as a single answer instead of spilling across prompts.
451
+ const ask = (q) => new Promise(resolve => {
452
+ let buffer = "";
453
+ let timer = null;
454
+ let done = false;
455
+ const finish = () => {
456
+ if (done)
457
+ return;
458
+ done = true;
459
+ rl.removeListener("line", onLine);
460
+ resolve(buffer.trim());
461
+ };
462
+ const onLine = (line) => {
463
+ buffer += (buffer ? "\n" : "") + line;
464
+ if (timer)
465
+ clearTimeout(timer);
466
+ timer = setTimeout(finish, 200);
467
+ };
468
+ process.stdout.write(q);
469
+ rl.on("line", onLine);
470
+ });
471
+ console.log("\n🎭 Agent personality setup");
472
+ console.log(" Soul entries shape what every future session starts with.\n");
473
+ console.log(" What best describes this agent?");
474
+ console.log(" (1) Solo developer — helps you with code on this machine");
475
+ console.log(" (2) Team agent — runs in a shared repo / ops flow");
476
+ console.log(" (3) Research assistant — surveys sources, writes notes");
477
+ console.log(" (4) Draft from Claude — paste a Claude-generated JSON draft");
478
+ console.log(" (5) Custom — I'll prompt for each field with examples");
479
+ console.log(" (s) Skip — set up later; `flair doctor` will nudge\n");
480
+ const choice = (await ask(" Choice [1-5/s]: ")).trim().toLowerCase();
481
+ let entries = [];
482
+ if (choice === "s" || choice === "skip") {
483
+ rl.close();
484
+ return [];
485
+ }
486
+ else if (choice === "1" || choice === "2" || choice === "3") {
487
+ entries = templateSoul(choice);
488
+ console.log("\n Template draft:");
489
+ for (const [k, v] of entries)
490
+ console.log(` ${k}: ${v}`);
491
+ const edit = (await ask("\n Edit before saving? [y/N]: ")).trim().toLowerCase();
492
+ if (edit === "y" || edit === "yes") {
493
+ entries = await editEntries(ask, entries);
494
+ }
495
+ }
496
+ else if (choice === "4") {
497
+ console.log("\n Paste this prompt into your Claude session:");
498
+ console.log(" ─────────────────────────────────────────────────────────────");
499
+ console.log(` Generate a JSON object with keys "role", "project", and`);
500
+ console.log(` "standards" suitable as Flair soul entries for an agent with`);
501
+ console.log(` id "${agentId}" operating in my current context. Each value`);
502
+ console.log(` should be 1-2 specific sentences that shape behavior. Output`);
503
+ console.log(` only the JSON object, no prose.`);
504
+ console.log(" ─────────────────────────────────────────────────────────────\n");
505
+ console.log(" Paste the resulting JSON below:");
506
+ const raw = await ask(" > ");
507
+ try {
508
+ entries = parseSoulJson(raw);
509
+ console.log("\n Parsed draft:");
510
+ for (const [k, v] of entries)
511
+ console.log(` ${k}: ${v}`);
512
+ const edit = (await ask("\n Edit before saving? [y/N]: ")).trim().toLowerCase();
513
+ if (edit === "y" || edit === "yes") {
514
+ entries = await editEntries(ask, entries);
515
+ }
516
+ }
517
+ catch (err) {
518
+ console.log(`\n Couldn't parse JSON (${err.message}). Falling back to custom prompts.`);
519
+ entries = await customSoulPrompts(ask);
520
+ }
521
+ }
522
+ else {
523
+ // Custom (5) or unrecognized input — route to custom prompts
524
+ entries = await customSoulPrompts(ask);
525
+ }
526
+ rl.close();
527
+ return entries.filter(([, v]) => v.trim().length > 0);
528
+ }
326
529
  // ─── Program ─────────────────────────────────────────────────────────────────
327
530
  // Read version from package.json at the package root
328
531
  const __pkgDir = join(import.meta.dirname ?? __dirname, "..");
@@ -566,43 +769,15 @@ program
566
769
  }
567
770
  console.log(`\n Export: FLAIR_URL=${httpUrl}`);
568
771
  // ── First-run soul setup ──────────────────────────────────────────────
569
- // Interactive prompts to set initial personality. Skipped with --skip-soul
570
- // or when stdin is not a TTY (CI, scripts, piped input).
772
+ // Interactive wizard to set initial personality (see runSoulWizard).
773
+ // Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
774
+ //
775
+ // Non-TTY / --skip-soul used to seed placeholder text like
776
+ // "AI assistant [default]" — it leaked into bootstrap output and
777
+ // confused users. Now those paths leave the soul empty and nudge the
778
+ // user toward `flair soul set` / `flair doctor` instead.
571
779
  if (!opts.skipSoul && process.stdin.isTTY) {
572
- console.log("\n🎭 Set up agent personality (press Enter to skip any):\n");
573
- const { createInterface } = await import("node:readline");
574
- const rl = createInterface({ input: process.stdin, output: process.stdout });
575
- // Buffered ask: collects rapid input (pasted text) into one answer.
576
- // Waits 200ms after last line before resolving, so pasted multiline
577
- // blocks are captured as a single answer instead of spilling across prompts.
578
- const ask = (q) => new Promise(resolve => {
579
- let buffer = "";
580
- let timer = null;
581
- const finish = () => {
582
- rl.removeListener("line", onLine);
583
- resolve(buffer.trim());
584
- };
585
- const onLine = (line) => {
586
- buffer += (buffer ? "\n" : "") + line;
587
- if (timer)
588
- clearTimeout(timer);
589
- timer = setTimeout(finish, 200);
590
- };
591
- process.stdout.write(q);
592
- rl.on("line", onLine);
593
- });
594
- const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
595
- const project = await ask(" What project is it working on?\n > ");
596
- const standards = await ask(" Any coding standards or preferences?\n > ");
597
- rl.close();
598
- // Write non-empty answers as soul entries
599
- const soulEntries = [];
600
- if (role.trim())
601
- soulEntries.push(["role", role.trim()]);
602
- if (project.trim())
603
- soulEntries.push(["project", project.trim()]);
604
- if (standards.trim())
605
- soulEntries.push(["standards", standards.trim()]);
780
+ const soulEntries = await runSoulWizard(agentId);
606
781
  if (soulEntries.length > 0) {
607
782
  console.log("");
608
783
  for (const [key, value] of soulEntries) {
@@ -614,30 +789,19 @@ program
614
789
  console.warn(` ⚠ soul:${key} failed: ${err.message}`);
615
790
  }
616
791
  }
617
- console.log(`\n ${soulEntries.length} soul entries saved. Bootstrap will include them.`);
792
+ console.log(`\n ${soulEntries.length} soul entries saved.`);
793
+ console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
618
794
  }
619
795
  else {
620
- console.log("\n No soul entries you can add them later with: flair soul set --agent " + agentId + " --key role --value \"...\"");
796
+ console.log(`\n No soul entries saved. Add later with:`);
797
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
798
+ console.log(` Or run \`flair doctor\` anytime for a nudge.`);
621
799
  }
622
800
  }
623
801
  else {
624
- // --skip-soul or non-interactive: seed sensible defaults so bootstrap returns useful context
625
- const defaultSoulEntries = [
626
- ["role", "AI assistant [default customize with 'flair soul set']"],
627
- ["personality", "Helpful, precise, and proactive [default — customize with 'flair soul set']"],
628
- ["constraints", "Respect user privacy. Be concise. [default — customize with 'flair soul set']"],
629
- ];
630
- console.log("\nSeeding default soul entries...");
631
- for (const [key, value] of defaultSoulEntries) {
632
- try {
633
- await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
634
- console.log(` ✓ soul:${key} set (default)`);
635
- }
636
- catch (err) {
637
- console.warn(` ⚠ soul:${key} failed: ${err.message}`);
638
- }
639
- }
640
- console.log(` Customize with: flair soul set --agent ${agentId} --key role --value "..."`);
802
+ const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
803
+ console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
804
+ console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
641
805
  }
642
806
  console.log(`\n Claude Code: Add to your CLAUDE.md:`);
643
807
  console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
@@ -1824,23 +1988,81 @@ rem
1824
1988
  }
1825
1989
  });
1826
1990
  // ─── flair status ─────────────────────────────────────────────────────────────
1827
- program
1828
- .command("status")
1829
- .description("Show Flair instance status, memory stats, and agent info")
1830
- .option("--port <port>", "Harper HTTP port")
1831
- .option("--url <url>", "Flair base URL (overrides --port)")
1832
- .option("--json", "Output as JSON")
1833
- .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
1834
- .action(async (opts) => {
1991
+ function humanBytes(n) {
1992
+ if (!Number.isFinite(n) || n < 0)
1993
+ return "—";
1994
+ if (n < 1024)
1995
+ return `${n} B`;
1996
+ if (n < 1024 * 1024)
1997
+ return `${(n / 1024).toFixed(1)} KB`;
1998
+ if (n < 1024 * 1024 * 1024)
1999
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
2000
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
2001
+ }
2002
+ function relativeTime(iso) {
2003
+ if (!iso)
2004
+ return "—";
2005
+ const ago = Date.now() - new Date(iso).getTime();
2006
+ if (!Number.isFinite(ago) || ago < 0)
2007
+ return "—";
2008
+ const mins = Math.floor(ago / 60000);
2009
+ const hrs = Math.floor(ago / 3600000);
2010
+ const days = Math.floor(ago / 86400000);
2011
+ return days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
2012
+ }
2013
+ // Renders OAuth status lines from non-secret metadata. /HealthDetail never
2014
+ // returns clientSecret — only counts and identifying fields (id, name,
2015
+ // registeredBy, createdAt, issuer). Inputs are coerced to scalar primitives
2016
+ // before formatting to keep the display values clearly separated from the
2017
+ // source record.
2018
+ function oauthSummaryLines(o) {
2019
+ const clients = Number(o?.clients ?? 0);
2020
+ const idps = Number(o?.idpConfigs ?? 0);
2021
+ const tokens = Number(o?.activeTokens ?? 0);
2022
+ return [
2023
+ "\nOAuth:",
2024
+ ` Clients: ${clients} IdPs: ${idps} Active tokens: ${tokens}`,
2025
+ ];
2026
+ }
2027
+ function oauthDetailLines(o) {
2028
+ const clients = Number(o?.clients ?? 0);
2029
+ const idps = Number(o?.idpConfigs ?? 0);
2030
+ const tokens = Number(o?.activeTokens ?? 0);
2031
+ const out = [
2032
+ "OAuth:",
2033
+ ` Clients: ${clients}`,
2034
+ ` IdP configs: ${idps}`,
2035
+ ` Active tokens: ${tokens}`,
2036
+ ];
2037
+ if (Array.isArray(o?.clientList) && o.clientList.length > 0) {
2038
+ out.push("", " Clients:");
2039
+ for (const c of o.clientList) {
2040
+ const id = String(c?.id ?? "");
2041
+ const name = String(c?.name ?? "—");
2042
+ const registeredBy = String(c?.registeredBy ?? "—");
2043
+ const createdAt = String(c?.createdAt ?? "—");
2044
+ out.push(` ${id} ${name} ${registeredBy} ${createdAt}`);
2045
+ }
2046
+ }
2047
+ if (Array.isArray(o?.idpList) && o.idpList.length > 0) {
2048
+ out.push("", " IdPs:");
2049
+ for (const i of o.idpList) {
2050
+ const id = String(i?.id ?? "");
2051
+ const name = String(i?.name ?? "—");
2052
+ const issuer = String(i?.issuer ?? "—");
2053
+ out.push(` ${id} ${name} ${issuer}`);
2054
+ }
2055
+ }
2056
+ return out;
2057
+ }
2058
+ async function fetchHealthDetail(opts) {
1835
2059
  const port = resolveHttpPort(opts);
1836
2060
  const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
1837
2061
  let healthy = false;
1838
2062
  let healthData = null;
1839
- // 1. Basic health check — try unauthenticated first, then with admin auth
1840
2063
  try {
1841
2064
  let res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
1842
2065
  if (!res.ok && res.status === 401) {
1843
- // Harper requires auth (authorizeLocal: true) — retry with admin credentials
1844
2066
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
1845
2067
  if (adminPass) {
1846
2068
  res = await fetch(`${baseUrl}/Health`, {
@@ -1852,7 +2074,6 @@ program
1852
2074
  healthy = res.ok;
1853
2075
  }
1854
2076
  catch { /* unreachable */ }
1855
- // 2. Try authenticated /HealthDetail for rich stats
1856
2077
  if (healthy) {
1857
2078
  const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
1858
2079
  if (agentId) {
@@ -1864,14 +2085,12 @@ program
1864
2085
  headers: { Authorization: authHeader },
1865
2086
  signal: AbortSignal.timeout(5000),
1866
2087
  });
1867
- if (res.ok) {
2088
+ if (res.ok)
1868
2089
  healthData = await res.json().catch(() => null);
1869
- }
1870
2090
  }
1871
- catch { /* fall through to basic output */ }
2091
+ catch { /* fall through */ }
1872
2092
  }
1873
2093
  }
1874
- // Fallback: try admin basic auth if available
1875
2094
  if (!healthData) {
1876
2095
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
1877
2096
  if (adminPass) {
@@ -1881,14 +2100,24 @@ program
1881
2100
  headers: { Authorization: auth },
1882
2101
  signal: AbortSignal.timeout(5000),
1883
2102
  });
1884
- if (res.ok) {
2103
+ if (res.ok)
1885
2104
  healthData = await res.json().catch(() => null);
1886
- }
1887
2105
  }
1888
- catch { /* fall through to basic output */ }
2106
+ catch { /* fall through */ }
1889
2107
  }
1890
2108
  }
1891
2109
  }
2110
+ return { healthy, baseUrl, healthData };
2111
+ }
2112
+ const statusCmd = program
2113
+ .command("status")
2114
+ .description("Show Flair instance status, memory stats, and agent info")
2115
+ .option("--port <port>", "Harper HTTP port")
2116
+ .option("--url <url>", "Flair base URL (overrides --port)")
2117
+ .option("--json", "Output as JSON")
2118
+ .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
2119
+ .action(async (opts) => {
2120
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
1892
2121
  if (opts.json) {
1893
2122
  console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
1894
2123
  if (!healthy)
@@ -1901,7 +2130,6 @@ program
1901
2130
  console.log(`\n Run: flair start or flair doctor`);
1902
2131
  process.exit(1);
1903
2132
  }
1904
- // Format uptime
1905
2133
  const uptimeSec = healthData?.uptimeSeconds;
1906
2134
  let uptimeStr = "";
1907
2135
  if (uptimeSec != null) {
@@ -1910,41 +2138,268 @@ program
1910
2138
  const m = Math.floor((uptimeSec % 3600) / 60);
1911
2139
  uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
1912
2140
  }
1913
- // Format last write as relative time
1914
- let lastWriteStr = "";
1915
- if (healthData?.lastWrite) {
1916
- const ago = Date.now() - new Date(healthData.lastWrite).getTime();
1917
- const mins = Math.floor(ago / 60000);
1918
- const hrs = Math.floor(ago / 3600000);
1919
- const days = Math.floor(ago / 86400000);
1920
- lastWriteStr = days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
1921
- }
1922
2141
  const pid = healthData?.pid ?? "";
1923
2142
  const agents = healthData?.agents;
1924
2143
  const memories = healthData?.memories;
1925
- console.log(`Flair v${__pkgVersion} 🟢 running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
2144
+ const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
2145
+ const hasWarn = warnings.some((w) => w.level === "warn");
2146
+ // Header state-word stays "running" whenever the process is alive; the
2147
+ // icon conveys health (🟢 clean / 🟡 warnings). 🔴 unreachable is already
2148
+ // handled above by the `!healthy` early-exit. Decoupling state from
2149
+ // health keeps the smoke-test `grep -q "running"` stable across tiers.
2150
+ const headerIcon = hasWarn ? "🟡" : "🟢";
2151
+ console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
1926
2152
  console.log(` URL: ${baseUrl}`);
2153
+ if (warnings.length > 0) {
2154
+ console.log(`\n⚠ Warnings: ${warnings.length}`);
2155
+ for (const w of warnings)
2156
+ console.log(` • ${w.level} ${w.message}`);
2157
+ }
1927
2158
  if (memories) {
1928
- const embStr = memories.withEmbeddings > 0
1929
- ? `${memories.withEmbeddings} with embeddings`
1930
- : "";
1931
- const hashStr = memories.hashFallback > 0
1932
- ? `${memories.hashFallback} hash-fallback`
1933
- : "";
2159
+ console.log("\nMemory:");
2160
+ const embStr = memories.withEmbeddings > 0 ? `${memories.withEmbeddings} embedded` : "";
2161
+ const hashStr = memories.hashFallback > 0 ? `${memories.hashFallback} hash` : "";
1934
2162
  const detail = [embStr, hashStr].filter(Boolean).join(", ");
1935
- console.log(` Memories: ${memories.total}${detail ? ` (${detail})` : ""}`);
2163
+ console.log(` Total: ${memories.total}${detail ? ` (${detail})` : ""}`);
2164
+ if (memories.modelCounts && typeof memories.modelCounts === "object") {
2165
+ const entries = Object.entries(memories.modelCounts)
2166
+ .filter(([, n]) => n > 0)
2167
+ .sort((a, b) => b[1] - a[1]);
2168
+ if (entries.length > 0) {
2169
+ const formatted = entries.map(([k, n]) => `${k}: ${n}`).join(", ");
2170
+ console.log(` Embeddings: ${formatted}`);
2171
+ }
2172
+ }
2173
+ if (memories.byDurability) {
2174
+ const d = memories.byDurability;
2175
+ console.log(` Durability: ${d.permanent ?? 0} permanent / ${d.persistent ?? 0} persistent / ${d.standard ?? 0} standard / ${d.ephemeral ?? 0} ephemeral`);
2176
+ }
2177
+ if (typeof memories.archived === "number")
2178
+ console.log(` Archived: ${memories.archived}`);
2179
+ if (typeof memories.expired === "number" && memories.expired > 0)
2180
+ console.log(` Expired: ${memories.expired}`);
2181
+ if (healthData?.lastWrite)
2182
+ console.log(` Last write: ${relativeTime(healthData.lastWrite)}`);
2183
+ }
2184
+ if (agents && agents.count > 0) {
2185
+ console.log("\nAgents:");
2186
+ const nameStr = agents.names?.length > 0 ? ` — ${agents.names.join(", ")}` : "";
2187
+ console.log(` ${agents.count} total${nameStr}`);
2188
+ if (agents.count > 1 && Array.isArray(agents.perAgent) && agents.perAgent.length > 0) {
2189
+ const idW = Math.max(2, ...agents.perAgent.map((r) => (r.id ?? "").length));
2190
+ // Older HealthDetail responses only carry id / memoryCount / lastWriteAt.
2191
+ // Only print the richer columns if at least one row supplies them.
2192
+ const hasDeep = agents.perAgent.some((r) => typeof r.hashFallback === "number" || typeof r.writes24h === "number");
2193
+ if (hasDeep) {
2194
+ console.log(` ${"id".padEnd(idW)} memories hash_fb 24h last_write`);
2195
+ for (const r of agents.perAgent) {
2196
+ const fb = typeof r.hashFallback === "number" ? String(r.hashFallback) : "—";
2197
+ const w24 = typeof r.writes24h === "number" ? String(r.writes24h) : "—";
2198
+ console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${fb.padStart(7)} ${w24.padStart(3)} ${relativeTime(r.lastWriteAt)}`);
2199
+ }
2200
+ }
2201
+ else {
2202
+ console.log(` ${"id".padEnd(idW)} memories last_write`);
2203
+ for (const r of agents.perAgent) {
2204
+ console.log(` ${(r.id ?? "").padEnd(idW)} ${String(r.memoryCount).padStart(8)} ${relativeTime(r.lastWriteAt)}`);
2205
+ }
2206
+ }
2207
+ }
1936
2208
  }
1937
- if (agents) {
1938
- const nameStr = agents.names?.length > 0 ? ` (${agents.names.join(", ")})` : "";
1939
- console.log(` Agents: ${agents.count}${nameStr}`);
2209
+ if (healthData?.relationships) {
2210
+ const r = healthData.relationships;
2211
+ console.log("\nRelationships:");
2212
+ console.log(` ${r.total} total (${r.active} active)`);
2213
+ }
2214
+ if (healthData?.soul && healthData.soul.total > 0) {
2215
+ const s = healthData.soul;
2216
+ const bp = s.byPriority ?? {};
2217
+ console.log("\nSoul:");
2218
+ console.log(` ${s.total} entries — ${bp.critical ?? 0} critical / ${bp.high ?? 0} high / ${bp.standard ?? 0} standard / ${bp.low ?? 0} low`);
2219
+ }
2220
+ else if (typeof healthData?.soulEntries === "number" && healthData.soulEntries > 0) {
2221
+ console.log("\nSoul:");
2222
+ console.log(` ${healthData.soulEntries} entries`);
2223
+ }
2224
+ if (healthData?.rem) {
2225
+ const r = healthData.rem;
2226
+ console.log("\nREM:");
2227
+ if (r.lastLightAt)
2228
+ console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
2229
+ if (r.lastRapidAt)
2230
+ console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
2231
+ if (r.lastRestorativeAt)
2232
+ console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
2233
+ const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
2234
+ console.log(` Nightly: ${nightly}`);
2235
+ if (r.nightlyEnabled && r.lastNightlyAt)
2236
+ console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)}`);
2237
+ if (typeof r.pendingCandidates === "number" && r.pendingCandidates > 0) {
2238
+ console.log(` Pending candidates: ${r.pendingCandidates}`);
2239
+ }
2240
+ }
2241
+ if (healthData?.federation) {
2242
+ const f = healthData.federation;
2243
+ console.log("\nFederation:");
2244
+ if (f.instance)
2245
+ console.log(` Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
2246
+ if (f.peers)
2247
+ console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
2248
+ if (f.pendingTokens > 0)
2249
+ console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
2250
+ }
2251
+ if (healthData?.oauth) {
2252
+ const lines = oauthSummaryLines(healthData.oauth);
2253
+ for (const line of lines)
2254
+ console.log(line);
2255
+ }
2256
+ if (healthData?.bridges) {
2257
+ const b = healthData.bridges;
2258
+ console.log("\nBridges:");
2259
+ if (Array.isArray(b.installed) && b.installed.length > 0)
2260
+ console.log(` Installed: ${b.installed.join(", ")}`);
2261
+ if (b.lastImport)
2262
+ console.log(` Last import: ${relativeTime(b.lastImport)}`);
2263
+ if (b.lastExport)
2264
+ console.log(` Last export: ${relativeTime(b.lastExport)}`);
2265
+ }
2266
+ if (healthData?.disk) {
2267
+ const d = healthData.disk;
2268
+ console.log("\nDisk:");
2269
+ console.log(` Data: ${d.dataDir} — ${humanBytes(d.dataBytes ?? 0)}`);
2270
+ console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
2271
+ }
2272
+ console.log("");
2273
+ if (warnings.length > 0)
2274
+ console.log(` Health: ⚠ ${warnings.length} warning(s)`);
2275
+ else
2276
+ console.log(` Health: ✅ all checks passing`);
2277
+ });
2278
+ statusCmd
2279
+ .command("rem")
2280
+ .description("Show REM (memory hygiene) subsystem status")
2281
+ .action(async function () {
2282
+ const opts = this.optsWithGlobals();
2283
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2284
+ if (opts.json) {
2285
+ console.log(JSON.stringify({ healthy, rem: healthData?.rem ?? null }, null, 2));
2286
+ if (!healthy)
2287
+ process.exit(1);
2288
+ return;
2289
+ }
2290
+ if (!healthy) {
2291
+ console.log("🔴 unreachable");
2292
+ process.exit(1);
2293
+ }
2294
+ const r = healthData?.rem;
2295
+ if (!r) {
2296
+ console.log("REM: not configured (no log entries or platform timers found)");
2297
+ return;
2298
+ }
2299
+ console.log("REM:");
2300
+ console.log(` Last light: ${relativeTime(r.lastLightAt)}`);
2301
+ console.log(` Last rapid: ${relativeTime(r.lastRapidAt)}`);
2302
+ console.log(` Last restorative: ${relativeTime(r.lastRestorativeAt)}`);
2303
+ const nightly = r.nightlyEnabled === true ? "enabled" : r.nightlyEnabled === false ? "disabled" : "unknown";
2304
+ console.log(` Nightly: ${nightly}`);
2305
+ if (r.lastNightlyAt)
2306
+ console.log(` Last nightly: ${relativeTime(r.lastNightlyAt)} (${r.lastNightlyAt})`);
2307
+ if (typeof r.pendingCandidates === "number")
2308
+ console.log(` Pending candidates: ${r.pendingCandidates}`);
2309
+ else
2310
+ console.log(` Pending candidates: — (schema not available)`);
2311
+ });
2312
+ statusCmd
2313
+ .command("federation")
2314
+ .description("Show federation subsystem status")
2315
+ .action(async function () {
2316
+ const opts = this.optsWithGlobals();
2317
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2318
+ if (opts.json) {
2319
+ console.log(JSON.stringify({ healthy, federation: healthData?.federation ?? null }, null, 2));
2320
+ if (!healthy)
2321
+ process.exit(1);
2322
+ return;
2323
+ }
2324
+ if (!healthy) {
2325
+ console.log("🔴 unreachable");
2326
+ process.exit(1);
2327
+ }
2328
+ const f = healthData?.federation;
2329
+ if (!f) {
2330
+ console.log("Federation: not configured");
2331
+ return;
2332
+ }
2333
+ console.log("Federation:");
2334
+ if (f.instance)
2335
+ console.log(` Instance: ${f.instance.id} (${f.instance.role ?? "—"}, ${f.instance.status ?? "—"})`);
2336
+ else
2337
+ console.log(" Instance: —");
2338
+ if (f.peers)
2339
+ console.log(` Peers: ${f.peers.total} (${f.peers.connected} connected / ${f.peers.disconnected} down / ${f.peers.revoked} revoked)`);
2340
+ if (typeof f.pendingTokens === "number" && f.pendingTokens > 0)
2341
+ console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
2342
+ if (Array.isArray(f.peerList) && f.peerList.length > 0) {
2343
+ const idW = Math.max(4, ...f.peerList.map((p) => (p.id ?? "").length));
2344
+ console.log(`\n ${"peer".padEnd(idW)} ${"role".padEnd(5)} ${"status".padEnd(13)} last_sync`);
2345
+ for (const p of f.peerList) {
2346
+ console.log(` ${(p.id ?? "").padEnd(idW)} ${(p.role ?? "—").padEnd(5)} ${(p.status ?? "—").padEnd(13)} ${p.lastSyncAt ? `${relativeTime(p.lastSyncAt)} (${p.lastSyncAt})` : "never"}`);
2347
+ }
2348
+ }
2349
+ });
2350
+ statusCmd
2351
+ .command("auth")
2352
+ .description("Show OAuth / IdP subsystem status")
2353
+ .action(async function () {
2354
+ const opts = this.optsWithGlobals();
2355
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2356
+ if (opts.json) {
2357
+ console.log(JSON.stringify({ healthy, oauth: healthData?.oauth ?? null }, null, 2));
2358
+ if (!healthy)
2359
+ process.exit(1);
2360
+ return;
2361
+ }
2362
+ if (!healthy) {
2363
+ console.log("🔴 unreachable");
2364
+ process.exit(1);
2365
+ }
2366
+ const o = healthData?.oauth;
2367
+ if (!o) {
2368
+ console.log("OAuth: not configured");
2369
+ return;
2370
+ }
2371
+ const lines = oauthDetailLines(o);
2372
+ for (const line of lines)
2373
+ console.log(line);
2374
+ });
2375
+ statusCmd
2376
+ .command("bridges")
2377
+ .description("Show memory bridges subsystem status")
2378
+ .action(async function () {
2379
+ const opts = this.optsWithGlobals();
2380
+ const { healthy, healthData } = await fetchHealthDetail(opts);
2381
+ if (opts.json) {
2382
+ console.log(JSON.stringify({ healthy, bridges: healthData?.bridges ?? null }, null, 2));
2383
+ if (!healthy)
2384
+ process.exit(1);
2385
+ return;
1940
2386
  }
1941
- if (healthData?.soulEntries != null) {
1942
- console.log(` Soul: ${healthData.soulEntries} entries`);
2387
+ if (!healthy) {
2388
+ console.log("🔴 unreachable");
2389
+ process.exit(1);
1943
2390
  }
1944
- if (lastWriteStr) {
1945
- console.log(` Last write: ${lastWriteStr}`);
2391
+ const b = healthData?.bridges;
2392
+ if (!b) {
2393
+ console.log("Bridges: none installed (no flair-bridge-* packages found)");
2394
+ return;
1946
2395
  }
1947
- console.log(` Health: ✅ all checks passing`);
2396
+ console.log("Bridges:");
2397
+ if (Array.isArray(b.installed) && b.installed.length > 0)
2398
+ console.log(` Installed: ${b.installed.join(", ")}`);
2399
+ if (b.lastImport)
2400
+ console.log(` Last import: ${relativeTime(b.lastImport)}`);
2401
+ if (b.lastExport)
2402
+ console.log(` Last export: ${relativeTime(b.lastExport)}`);
1948
2403
  });
1949
2404
  // ─── flair upgrade ────────────────────────────────────────────────────────────
1950
2405
  program
@@ -1953,52 +2408,80 @@ program
1953
2408
  .option("--check", "Only check for updates, don't install")
1954
2409
  .option("--restart", "Restart Flair after upgrade")
1955
2410
  .action(async (opts) => {
1956
- const { execSync } = await import("node:child_process");
2411
+ const { execSync, execFileSync } = await import("node:child_process");
1957
2412
  const checkOnly = opts.check ?? false;
1958
2413
  console.log("Checking for updates...\n");
2414
+ // Per-package install probes. `npm list -g` assumed the default global
2415
+ // prefix and silently mis-reported "not installed" for anyone using
2416
+ // mise / fnm / nvm / volta / non-default-prefix npm — including the
2417
+ // running flair binary itself, which was obviously installed. Each
2418
+ // entry now has a locator that works regardless of install path:
2419
+ //
2420
+ // - For packages with a bin: shell out to the bin with --version
2421
+ // (same PATH lookup that got them invokable in the first place).
2422
+ // - For library packages: require.resolve the package.json from the
2423
+ // running flair's module graph (works whether it's a sibling
2424
+ // global install or a bundled dep).
1959
2425
  const packages = [
1960
- "@tpsdev-ai/flair",
1961
- "@tpsdev-ai/flair-client",
1962
- "@tpsdev-ai/flair-mcp",
2426
+ {
2427
+ name: "@tpsdev-ai/flair",
2428
+ probe: () => probeBinVersion(execFileSync, "flair"),
2429
+ },
2430
+ {
2431
+ name: "@tpsdev-ai/flair-client",
2432
+ probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
2433
+ },
2434
+ {
2435
+ name: "@tpsdev-ai/flair-mcp",
2436
+ probe: () => probeBinVersion(execFileSync, "flair-mcp"),
2437
+ },
1963
2438
  ];
1964
- const upgrades = [];
1965
- for (const pkg of packages) {
2439
+ const findings = [];
2440
+ for (const { name, probe } of packages) {
1966
2441
  try {
1967
- const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, { signal: AbortSignal.timeout(5000) });
2442
+ const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
1968
2443
  if (!res.ok)
1969
2444
  continue;
1970
2445
  const data = await res.json();
1971
2446
  const latest = data.version ?? "unknown";
1972
- let installed = "not installed";
1973
- try {
1974
- const out = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null || true`, { encoding: "utf-8" }).trim();
1975
- const match = out.match(/@(\d+\.\d+[\d.a-z-]*)/);
1976
- installed = match ? match[1] : "not installed";
1977
- }
1978
- catch { /* best effort */ }
1979
- const upToDate = installed === latest;
1980
- const icon = upToDate ? "✅" : "⬆️";
1981
- console.log(` ${icon} ${pkg}: ${installed} → ${latest}${upToDate ? " (current)" : ""}`);
1982
- if (!upToDate && installed !== "not installed") {
1983
- upgrades.push({ pkg, installed, latest });
1984
- }
2447
+ const installed = probe();
2448
+ const status = installed === null ? "missing" : installed === latest ? "current" : "outdated";
2449
+ findings.push({ name, installed, latest, status });
2450
+ const icon = status === "current" ? "✅" : status === "outdated" ? "⬆️" : "❔";
2451
+ const installedLabel = installed ?? "not detected";
2452
+ const suffix = status === "current" ? " (current)" : status === "missing" ? " (run: npm install -g)" : "";
2453
+ console.log(` ${icon} ${name}: ${installedLabel} ${latest}${suffix}`);
1985
2454
  }
1986
2455
  catch { /* skip unavailable packages */ }
1987
2456
  }
1988
- if (upgrades.length === 0) {
2457
+ const outdated = findings.filter((f) => f.status === "outdated");
2458
+ const missing = findings.filter((f) => f.status === "missing");
2459
+ const upgrades = outdated.map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
2460
+ if (outdated.length === 0 && missing.length === 0) {
1989
2461
  console.log("\n✅ Everything is up to date.");
1990
2462
  return;
1991
2463
  }
2464
+ if (missing.length > 0 && outdated.length === 0) {
2465
+ console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
2466
+ console.log(` Install missing: npm install -g ${missing.map((f) => f.name).join(" ")}`);
2467
+ return;
2468
+ }
1992
2469
  if (checkOnly) {
1993
- console.log(`\n${upgrades.length} update${upgrades.length > 1 ? "s" : ""} available. Run: flair upgrade`);
2470
+ console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade`);
2471
+ if (missing.length > 0) {
2472
+ console.log(`${missing.length} package${missing.length > 1 ? "s" : ""} not detected${missing.length > 0 ? ": " + missing.map((f) => f.name).join(", ") : ""}.`);
2473
+ }
1994
2474
  return;
1995
2475
  }
1996
- // Perform upgrade
2476
+ // Perform upgrade. `latest` comes from the npm registry's HTTP
2477
+ // response, so CodeQL (correctly) treats it as untrusted input.
2478
+ // Use execFileSync with argv — the spec `<name>@<version>` becomes a
2479
+ // single argument to npm, no shell to inject into.
1997
2480
  console.log(`\nUpgrading ${upgrades.length} package${upgrades.length > 1 ? "s" : ""}...\n`);
1998
2481
  for (const { pkg, latest } of upgrades) {
1999
2482
  try {
2000
2483
  console.log(` Installing ${pkg}@${latest}...`);
2001
- execSync(`npm install -g ${pkg}@${latest}`, { stdio: "pipe" });
2484
+ execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
2002
2485
  console.log(` ✅ ${pkg}@${latest} installed`);
2003
2486
  }
2004
2487
  catch (err) {
@@ -2440,19 +2923,21 @@ program
2440
2923
  failed++;
2441
2924
  }
2442
2925
  };
2443
- // 1. Write a test memory via POST /Memory
2444
- await check("Write test memory (POST /Memory)", async () => {
2926
+ // 1. Write a test memory via PUT /Memory/<id>.
2927
+ // Schema only exposes PUT — POST returns 'Memory does not have a post method implemented'.
2928
+ await check("Write test memory (PUT /Memory/<id>)", async () => {
2929
+ const id = `flair-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2445
2930
  const body = {
2931
+ id,
2446
2932
  content: "flair test \u2014 this will be deleted",
2447
2933
  durability: "ephemeral",
2448
2934
  createdAt: new Date().toISOString(),
2449
2935
  };
2450
2936
  if (agentId)
2451
2937
  body.agentId = agentId;
2452
- const result = await api("POST", "/Memory", body);
2453
- // id may be returned directly or nested
2454
- memoryId = result?.id ?? result?.[0]?.id ?? null;
2455
- return !!memoryId || result?.ok === true;
2938
+ await api("PUT", `/Memory/${id}`, body);
2939
+ memoryId = id;
2940
+ return true;
2456
2941
  });
2457
2942
  // 2. Search for the test memory via POST /SemanticSearch
2458
2943
  await check("Search for test memory (POST /SemanticSearch)", async () => {
@@ -2477,6 +2962,80 @@ program
2477
2962
  if (failed > 0)
2478
2963
  process.exit(1);
2479
2964
  });
2965
+ // ─── flair deploy ─────────────────────────────────────────────────────────────
2966
+ program
2967
+ .command("deploy")
2968
+ .description("Deploy Flair as a component to a remote Harper Fabric cluster")
2969
+ .option("--fabric-org <org>", "Fabric org (env: FABRIC_ORG)")
2970
+ .option("--fabric-cluster <cluster>", "Fabric cluster within the org (env: FABRIC_CLUSTER)")
2971
+ .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER)")
2972
+ .option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD)")
2973
+ .option("--fabric-token <token>", "OAuth bearer token (env: FABRIC_TOKEN) — reserved for future Fabric bearer support")
2974
+ .option("--target <url>", "Override the Fabric URL template (https://<cluster>.<org>.harperfabric.com)")
2975
+ .option("--project <name>", "Component name in Fabric", "flair")
2976
+ .option("--pkg-version <semver>", "Override version label (default: installed package version)")
2977
+ .option("--no-replicated", "Disable cluster-wide replication (default: replicated=true)")
2978
+ .option("--no-restart", "Do not restart the component after deploy (default: restart=true)")
2979
+ .option("--dry-run", "Resolve package, validate args, skip the deploy call")
2980
+ .option("--package-root <dir>", "Override package root (mainly for testing)")
2981
+ .action(async (opts) => {
2982
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
2983
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
2984
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
2985
+ const deployOpts = {
2986
+ fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
2987
+ fabricCluster: opts.fabricCluster ?? process.env.FABRIC_CLUSTER,
2988
+ fabricUser: opts.fabricUser ?? process.env.FABRIC_USER,
2989
+ fabricPassword: opts.fabricPassword ?? process.env.FABRIC_PASSWORD,
2990
+ fabricToken: opts.fabricToken ?? process.env.FABRIC_TOKEN,
2991
+ target: opts.target,
2992
+ project: opts.project,
2993
+ version: opts.pkgVersion,
2994
+ replicated: opts.replicated !== false,
2995
+ restart: opts.restart !== false,
2996
+ dryRun: opts.dryRun ?? false,
2997
+ packageRoot: opts.packageRoot,
2998
+ };
2999
+ const errors = validateDeployOptions(deployOpts);
3000
+ if (errors.length) {
3001
+ console.error(red("flair deploy: missing required options"));
3002
+ for (const e of errors)
3003
+ console.error(` - ${e}`);
3004
+ process.exit(1);
3005
+ }
3006
+ // Warn on password-via-flag (leaks to shell history). Env is preferred.
3007
+ if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
3008
+ console.error(dim("warning: --fabric-password leaks to shell history. " +
3009
+ "Prefer FABRIC_PASSWORD env."));
3010
+ }
3011
+ const url = buildDeployUrl(deployOpts);
3012
+ console.log(`${green("→")} Deploying ${deployOpts.project} to ${url}`);
3013
+ if (deployOpts.dryRun)
3014
+ console.log(dim(" (dry-run: skipping API call)"));
3015
+ try {
3016
+ const result = await deployToFabric(deployOpts);
3017
+ if (result.dryRun) {
3018
+ console.log(`${green("✓")} dry-run OK: ${result.project} ${result.version} ready to deploy to ${result.url}`);
3019
+ console.log(dim(` package root: ${result.packageRoot}`));
3020
+ return;
3021
+ }
3022
+ console.log(`\n${green("✓")} Flair ${result.version} deployed`);
3023
+ console.log(`\n URL: ${result.url}`);
3024
+ console.log(` Project: ${result.project}`);
3025
+ console.log(`\nNext steps:`);
3026
+ console.log(dim(` 1. Set an admin password in Fabric Studio (Cluster Settings → Admin)`));
3027
+ console.log(dim(` 2. Seed your first agent:`));
3028
+ console.log(` flair agent add --remote ${result.url} --name my-agent`);
3029
+ }
3030
+ catch (err) {
3031
+ console.error(red(`\n✗ deploy failed: ${err.message}`));
3032
+ const hint = err.message?.toLowerCase();
3033
+ if (hint?.includes("401") || hint?.includes("unauthoriz")) {
3034
+ console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
3035
+ }
3036
+ process.exit(1);
3037
+ }
3038
+ });
2480
3039
  // ─── flair doctor ─────────────────────────────────────────────────────────────
2481
3040
  program
2482
3041
  .command("doctor")
@@ -2730,23 +3289,81 @@ program
2730
3289
  });
2731
3290
  // ─── Memory and Soul commands ────────────────────────────────────────────────
2732
3291
  const memory = program.command("memory").description("Manage agent memories");
2733
- memory.command("add").requiredOption("--agent <id>").requiredOption("--content <text>")
3292
+ memory.command("add [content]").requiredOption("--agent <id>")
3293
+ .option("--content <text>", "memory content (alias for positional arg)")
2734
3294
  .option("--durability <d>", "standard").option("--tags <csv>")
2735
- .action(async (opts) => {
3295
+ .action(async (contentArg, opts) => {
3296
+ const content = contentArg ?? opts.content;
3297
+ if (!content) {
3298
+ console.error("error: content required (positional arg or --content)");
3299
+ process.exit(1);
3300
+ }
2736
3301
  const memId = `${opts.agent}-${Date.now()}`;
2737
3302
  const out = await api("PUT", `/Memory/${memId}`, {
2738
- id: memId, agentId: opts.agent, content: opts.content, durability: opts.durability || "standard",
3303
+ id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
2739
3304
  tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
2740
3305
  type: "memory", createdAt: new Date().toISOString(),
2741
3306
  });
2742
3307
  console.log(JSON.stringify(out, null, 2));
2743
3308
  });
2744
- memory.command("search").requiredOption("--agent <id>").requiredOption("--q <query>").option("--tag <tag>")
2745
- .action(async (opts) => console.log(JSON.stringify(await api("POST", "/SemanticSearch", { agentId: opts.agent, q: opts.q, tag: opts.tag }), null, 2)));
2746
- memory.command("list").requiredOption("--agent <id>").option("--tag <tag>")
3309
+ memory.command("search [query]").requiredOption("--agent <id>")
3310
+ .option("--q <query>", "search query (alias for positional arg)")
3311
+ .option("--limit <n>", "Max results", "5")
3312
+ .option("--tag <tag>")
3313
+ .action(async (queryArg, opts) => {
3314
+ const q = queryArg ?? opts.q;
3315
+ if (!q) {
3316
+ console.error("error: query required (positional arg or --q)");
3317
+ process.exit(1);
3318
+ }
3319
+ const body = { agentId: opts.agent, q, limit: parseInt(opts.limit, 10) || 5 };
3320
+ if (opts.tag)
3321
+ body.tag = opts.tag;
3322
+ console.log(JSON.stringify(await api("POST", "/SemanticSearch", body), null, 2));
3323
+ });
3324
+ memory.command("list")
3325
+ .requiredOption("--agent <id>")
3326
+ .option("--tag <tag>")
3327
+ .option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
3328
+ .option("--limit <n>", "Max rows when using --hash-fallback", "50")
2747
3329
  .action(async (opts) => {
2748
3330
  const q = new URLSearchParams({ agentId: opts.agent, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
2749
- console.log(JSON.stringify(await api("GET", `/Memory?${q}`), null, 2));
3331
+ const raw = await api("GET", `/Memory?${q}`);
3332
+ if (!opts.hashFallback) {
3333
+ console.log(JSON.stringify(raw, null, 2));
3334
+ return;
3335
+ }
3336
+ // --hash-fallback: filter to entries without a real embedding and print as a table.
3337
+ // Same predicate HealthDetail uses: missing model or the "hash-512d" marker.
3338
+ const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
3339
+ const fallback = all.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d");
3340
+ if (fallback.length === 0) {
3341
+ console.log(`No hash-fallback memories for agent ${opts.agent}. All embedded.`);
3342
+ return;
3343
+ }
3344
+ const limit = Math.max(1, parseInt(opts.limit, 10) || 50);
3345
+ const rows = fallback
3346
+ .slice()
3347
+ .sort((a, b) => {
3348
+ const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
3349
+ const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
3350
+ return tb - ta;
3351
+ })
3352
+ .slice(0, limit);
3353
+ const idW = Math.max(2, ...rows.map((r) => String(r.id ?? "").length));
3354
+ console.log(`${fallback.length} hash-fallback memories for agent ${opts.agent} (showing ${rows.length}):\n`);
3355
+ console.log(` ${"id".padEnd(idW)} created_at preview`);
3356
+ for (const r of rows) {
3357
+ const created = r.createdAt ? String(r.createdAt).slice(0, 19).replace("T", " ") : "—".padEnd(19);
3358
+ const preview = String(r.content ?? "").replace(/\s+/g, " ").slice(0, 80);
3359
+ console.log(` ${String(r.id ?? "").padEnd(idW)} ${created} ${preview}`);
3360
+ }
3361
+ if (fallback.length > rows.length) {
3362
+ console.log(`\n... ${fallback.length - rows.length} more (raise with --limit). To backfill: flair reembed --agent ${opts.agent} --stale-only`);
3363
+ }
3364
+ else {
3365
+ console.log(`\nTo backfill: flair reembed --agent ${opts.agent} --stale-only`);
3366
+ }
2750
3367
  });
2751
3368
  // ─── flair search (top-level shortcut) ───────────────────────────────────────
2752
3369
  program
@@ -2843,6 +3460,383 @@ soul.command("set").requiredOption("--agent <id>").requiredOption("--key <key>")
2843
3460
  soul.command("get").argument("<id>").action(async (id) => console.log(JSON.stringify(await api("GET", `/Soul/${id}`), null, 2)));
2844
3461
  soul.command("list").requiredOption("--agent <id>")
2845
3462
  .action(async (opts) => console.log(JSON.stringify(await api("GET", `/Soul?agentId=${encodeURIComponent(opts.agent)}`), null, 2)));
3463
+ // ─── flair bridge ────────────────────────────────────────────────────────────
3464
+ // Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
3465
+ // + agentic-stack reference adapter as a built-in.
3466
+ // `test` and `export` are still stubbed; Shape B (npm code plugins) too.
3467
+ // See specs/FLAIR-BRIDGES.md.
3468
+ const bridge = program.command("bridge").description("Manage memory bridges (import/export between Flair and foreign systems)");
3469
+ bridge
3470
+ .command("list")
3471
+ .description("List installed bridges across project YAML, user YAML, npm packages, and built-ins")
3472
+ .option("--json", "Output as JSON")
3473
+ .action(async (opts) => {
3474
+ const { discover } = await import("./bridges/discover.js");
3475
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3476
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3477
+ if (opts.json) {
3478
+ console.log(JSON.stringify(found, null, 2));
3479
+ return;
3480
+ }
3481
+ if (found.length === 0) {
3482
+ console.log("No bridges installed.");
3483
+ console.log("Add one with: flair bridge scaffold <name> --file");
3484
+ console.log("Or install from npm: npm install flair-bridge-<name>");
3485
+ return;
3486
+ }
3487
+ const nameW = Math.max(4, ...found.map((b) => b.name.length));
3488
+ const kindW = Math.max(4, ...found.map((b) => b.kind.length));
3489
+ const srcW = Math.max(6, ...found.map((b) => b.source.length));
3490
+ console.log(` ${"name".padEnd(nameW)} ${"kind".padEnd(kindW)} ${"source".padEnd(srcW)} description`);
3491
+ for (const b of found) {
3492
+ const desc = b.description ?? "";
3493
+ console.log(` ${b.name.padEnd(nameW)} ${b.kind.padEnd(kindW)} ${b.source.padEnd(srcW)} ${desc}`);
3494
+ }
3495
+ });
3496
+ bridge
3497
+ .command("scaffold <name>")
3498
+ .description("Emit starter files for a new bridge. Choose --file (YAML, declarative) or --api (TS code plugin)")
3499
+ .option("--file", "YAML file-format bridge (shape A)")
3500
+ .option("--api", "TypeScript API bridge (shape B)")
3501
+ .option("--force", "Overwrite existing files")
3502
+ .action(async (name, opts) => {
3503
+ if (opts.file && opts.api) {
3504
+ console.error("Pick one: --file or --api.");
3505
+ process.exit(1);
3506
+ }
3507
+ const { BUILTIN_BY_NAME } = await import("./bridges/builtins/index.js");
3508
+ if (BUILTIN_BY_NAME.has(name)) {
3509
+ console.error(`"${name}" is a built-in bridge name and can't be scaffolded — pick a different name.`);
3510
+ process.exit(1);
3511
+ }
3512
+ const kind = opts.api ? "api" : "file"; // --file is default
3513
+ const { scaffold } = await import("./bridges/scaffold.js");
3514
+ try {
3515
+ const result = await scaffold({ name, kind, force: !!opts.force });
3516
+ if (result.createdFiles.length > 0) {
3517
+ console.log(`Created ${result.createdFiles.length} file(s):`);
3518
+ for (const p of result.createdFiles)
3519
+ console.log(` + ${p}`);
3520
+ }
3521
+ if (result.skippedFiles.length > 0) {
3522
+ console.log(`Skipped ${result.skippedFiles.length} existing file(s) (pass --force to overwrite):`);
3523
+ for (const p of result.skippedFiles)
3524
+ console.log(` · ${p}`);
3525
+ }
3526
+ console.log(`\n${result.summary}`);
3527
+ }
3528
+ catch (err) {
3529
+ console.error(`Scaffold failed: ${err.message}`);
3530
+ process.exit(1);
3531
+ }
3532
+ });
3533
+ bridge
3534
+ .command("import <name> [src]")
3535
+ .description("Import memories from a foreign system into Flair via a bridge (Shape A YAML / built-in)")
3536
+ .option("--agent <id>", "Default agent ID for memories that don't carry one (or set FLAIR_AGENT_ID)")
3537
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative paths resolve against (default: cwd)")
3538
+ .option("--dry-run", "Validate + count, don't write to Flair")
3539
+ .option("--port <port>", "Harper HTTP port")
3540
+ .option("--url <url>", "Flair base URL (overrides --port)")
3541
+ .option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
3542
+ .action(async (name, srcArg, opts) => {
3543
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
3544
+ const cwd = opts.cwd ?? srcArg ?? process.cwd();
3545
+ const { discover } = await import("./bridges/discover.js");
3546
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3547
+ const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3548
+ const { runImport } = await import("./bridges/runtime/import-runner.js");
3549
+ const { makeContext } = await import("./bridges/runtime/context.js");
3550
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3551
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3552
+ const target = found.find((b) => b.name === name);
3553
+ if (!target) {
3554
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3555
+ process.exit(1);
3556
+ }
3557
+ let descriptor;
3558
+ try {
3559
+ descriptor = await loadDescriptor(target);
3560
+ }
3561
+ catch (err) {
3562
+ printBridgeError(err);
3563
+ process.exit(1);
3564
+ }
3565
+ const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3566
+ const ctx = makeContext({ bridge: name });
3567
+ // Memory POST: Ed25519-signed when an agent key is available, fall back
3568
+ // to the shared `api()` helper otherwise. Mirrors how `flair memory add`
3569
+ // works (see the `memory.command("add")` handler above).
3570
+ const putMemory = async (body) => {
3571
+ const headers = { "content-type": "application/json" };
3572
+ const keyPath = opts.key ?? resolveKeyPath(body.agentId);
3573
+ if (keyPath) {
3574
+ headers["authorization"] = buildEd25519Auth(body.agentId, "PUT", `/Memory/${body.id}`, keyPath);
3575
+ }
3576
+ const res = await fetch(`${baseUrl}/Memory/${encodeURIComponent(body.id)}`, {
3577
+ method: "PUT",
3578
+ headers,
3579
+ body: JSON.stringify(body),
3580
+ });
3581
+ if (!res.ok) {
3582
+ const text = await res.text().catch(() => "");
3583
+ throw new Error(`PUT /Memory/${body.id} → ${res.status}: ${text || res.statusText}`);
3584
+ }
3585
+ };
3586
+ let lastReportedAt = Date.now();
3587
+ let lastReportedOrdinal = 0;
3588
+ const onProgress = (ev) => {
3589
+ // Throttle in-progress chatter to at most one line every 2s + the
3590
+ // final summary. Avoids flooding stdout for big imports.
3591
+ if (ev.type === "done") {
3592
+ const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
3593
+ if (opts.dryRun) {
3594
+ console.log(`\n${descriptor.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
3595
+ }
3596
+ else {
3597
+ console.log(`\n${descriptor.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
3598
+ }
3599
+ return;
3600
+ }
3601
+ const now = Date.now();
3602
+ if (now - lastReportedAt < 2000 && ev.ordinal - lastReportedOrdinal < 25)
3603
+ return;
3604
+ lastReportedAt = now;
3605
+ lastReportedOrdinal = ev.ordinal;
3606
+ if (ev.type === "memory-imported") {
3607
+ process.stdout.write(`\r ${ev.ordinal} imported (${ev.foreignId ?? ev.flairId})`.padEnd(80));
3608
+ }
3609
+ else if (ev.type === "memory-skipped") {
3610
+ process.stdout.write(`\r ${ev.ordinal} skipped (${ev.reason})`.padEnd(80));
3611
+ }
3612
+ };
3613
+ try {
3614
+ await runImport({
3615
+ descriptor,
3616
+ cwd,
3617
+ agentId,
3618
+ dryRun: !!opts.dryRun,
3619
+ putMemory,
3620
+ onProgress,
3621
+ ctx,
3622
+ });
3623
+ }
3624
+ catch (err) {
3625
+ if (err instanceof BridgeRuntimeError) {
3626
+ printBridgeError(err);
3627
+ process.exit(1);
3628
+ }
3629
+ console.error(`Bridge import failed: ${err?.message ?? err}`);
3630
+ process.exit(1);
3631
+ }
3632
+ });
3633
+ bridge
3634
+ .command("export <name> <dst>")
3635
+ .description("Export memories from Flair to a foreign system via a bridge (Shape A YAML / built-in)")
3636
+ .requiredOption("--agent <id>", "Agent ID to export memories for (or set FLAIR_AGENT_ID)")
3637
+ .option("--source <tag>", "Filter to memories with a matching `source:` tag (typical for round-tripping a single bridge's data)")
3638
+ .option("--subject <subj>", "Filter to memories with a matching `subject:` tag")
3639
+ .option("--since <iso>", "Only memories with createdAt >= this ISO-8601 timestamp")
3640
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative target paths resolve against (default: cwd)")
3641
+ .option("--dry-run", "Validate + count + apply maps, don't write to the target")
3642
+ .option("--port <port>", "Harper HTTP port")
3643
+ .option("--url <url>", "Flair base URL (overrides --port)")
3644
+ .option("--key <path>", "Ed25519 private key path (default: resolved from agent)")
3645
+ .action(async (name, dst, opts) => {
3646
+ const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
3647
+ if (!agentId) {
3648
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
3649
+ process.exit(1);
3650
+ }
3651
+ const cwd = opts.cwd ?? dst;
3652
+ const { discover } = await import("./bridges/discover.js");
3653
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3654
+ const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3655
+ const { runExport } = await import("./bridges/runtime/export-runner.js");
3656
+ const { makeContext } = await import("./bridges/runtime/context.js");
3657
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3658
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3659
+ const target = found.find((b) => b.name === name);
3660
+ if (!target) {
3661
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3662
+ process.exit(1);
3663
+ }
3664
+ let descriptor;
3665
+ try {
3666
+ descriptor = await loadDescriptor(target);
3667
+ }
3668
+ catch (err) {
3669
+ printBridgeError(err);
3670
+ process.exit(1);
3671
+ }
3672
+ if (!descriptor.export) {
3673
+ console.error(`Bridge "${name}" has no export block — cannot export through it.`);
3674
+ process.exit(1);
3675
+ }
3676
+ const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3677
+ const ctx = makeContext({ bridge: name });
3678
+ // Memory fetcher — paginates GET /Memory?agentId=... applying any
3679
+ // descriptor + caller-side filters in memory. Slice 3a does the
3680
+ // simplest thing: one round trip, no streaming. Slice 3b can move
3681
+ // to cursor-paginated streaming if real corpora warrant it.
3682
+ const fetchMemories = async function* (filters) {
3683
+ const params = new URLSearchParams({ agentId });
3684
+ if (opts.subject)
3685
+ params.set("subject", opts.subject);
3686
+ const headers = { "content-type": "application/json" };
3687
+ const keyPath = opts.key ?? resolveKeyPath(agentId);
3688
+ const path = `/Memory?${params.toString()}`;
3689
+ if (keyPath)
3690
+ headers["authorization"] = buildEd25519Auth(agentId, "GET", path, keyPath);
3691
+ const res = await fetch(`${baseUrl}${path}`, { headers });
3692
+ if (!res.ok) {
3693
+ const text = await res.text().catch(() => "");
3694
+ throw new Error(`GET /Memory → ${res.status}: ${text || res.statusText}`);
3695
+ }
3696
+ const raw = await res.json();
3697
+ const all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
3698
+ const sourceFilter = opts.source;
3699
+ const sinceMs = opts.since ? new Date(opts.since).getTime() : null;
3700
+ for (const m of all) {
3701
+ if (sourceFilter && m.source !== sourceFilter)
3702
+ continue;
3703
+ if (sinceMs !== null && m.createdAt && new Date(m.createdAt).getTime() < sinceMs)
3704
+ continue;
3705
+ yield m;
3706
+ }
3707
+ void filters;
3708
+ };
3709
+ let lastReportedAt = Date.now();
3710
+ const onProgress = (ev) => {
3711
+ if (ev.type === "done") {
3712
+ if (opts.dryRun) {
3713
+ 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.`);
3714
+ }
3715
+ else {
3716
+ console.log(`\n${descriptor.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
3717
+ }
3718
+ return;
3719
+ }
3720
+ if (ev.type === "target-write") {
3721
+ console.log(` ✓ ${ev.path} (${ev.written} record${ev.written === 1 ? "" : "s"})`);
3722
+ return;
3723
+ }
3724
+ if (ev.type === "target-skipped") {
3725
+ console.log(` · ${ev.path} skipped (${ev.reason})`);
3726
+ return;
3727
+ }
3728
+ // memory-skipped events throttled to one line every 2s
3729
+ const now = Date.now();
3730
+ if (now - lastReportedAt < 2000)
3731
+ return;
3732
+ lastReportedAt = now;
3733
+ process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
3734
+ };
3735
+ try {
3736
+ await runExport({
3737
+ descriptor,
3738
+ cwd,
3739
+ fetchMemories,
3740
+ filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
3741
+ dryRun: !!opts.dryRun,
3742
+ ctx,
3743
+ onProgress,
3744
+ });
3745
+ }
3746
+ catch (err) {
3747
+ if (err instanceof BridgeRuntimeError) {
3748
+ printBridgeError(err);
3749
+ process.exit(1);
3750
+ }
3751
+ console.error(`Bridge export failed: ${err?.message ?? err}`);
3752
+ process.exit(1);
3753
+ }
3754
+ });
3755
+ bridge
3756
+ .command("test <name>")
3757
+ .description("Round-trip a bridge through its fixture: import → export → re-import → diff. Pass iff the stable fields (content/subject/tags/durability) match.")
3758
+ .option("--fixture <path>", "Override the import source path (defaults to descriptor's import.sources[0].path)")
3759
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative paths resolve against (default: cwd)")
3760
+ .option("--json", "Emit the full RoundTripResult as JSON on stdout")
3761
+ .action(async (name, opts) => {
3762
+ const cwd = opts.cwd ?? process.cwd();
3763
+ const { discover } = await import("./bridges/discover.js");
3764
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3765
+ const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3766
+ const { runRoundTrip } = await import("./bridges/runtime/roundtrip.js");
3767
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3768
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3769
+ const target = found.find((b) => b.name === name);
3770
+ if (!target) {
3771
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3772
+ process.exit(1);
3773
+ }
3774
+ let descriptor;
3775
+ try {
3776
+ descriptor = await loadDescriptor(target);
3777
+ }
3778
+ catch (err) {
3779
+ printBridgeError(err);
3780
+ process.exit(1);
3781
+ }
3782
+ try {
3783
+ const result = await runRoundTrip({ descriptor, cwd, fixturePath: opts.fixture });
3784
+ if (opts.json) {
3785
+ console.log(JSON.stringify(result, null, 2));
3786
+ process.exit(result.passed ? 0 : 1);
3787
+ }
3788
+ if (result.passed) {
3789
+ console.log(`✅ ${descriptor.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
3790
+ process.exit(0);
3791
+ }
3792
+ console.log(`❌ ${descriptor.name} round-trip failed.`);
3793
+ console.log(` expected ${result.expectedCount} records, got ${result.actualCount} back.`);
3794
+ if (result.missingInPass2.length > 0) {
3795
+ console.log(` missing from re-import (${result.missingInPass2.length}):`);
3796
+ for (const m of result.missingInPass2.slice(0, 5))
3797
+ console.log(` - ${m.key}`);
3798
+ if (result.missingInPass2.length > 5)
3799
+ console.log(` ... ${result.missingInPass2.length - 5} more`);
3800
+ }
3801
+ if (result.unexpectedInPass2.length > 0) {
3802
+ console.log(` unexpected extras in re-import (${result.unexpectedInPass2.length}):`);
3803
+ for (const m of result.unexpectedInPass2.slice(0, 5))
3804
+ console.log(` - ${m.key}`);
3805
+ if (result.unexpectedInPass2.length > 5)
3806
+ console.log(` ... ${result.unexpectedInPass2.length - 5} more`);
3807
+ }
3808
+ if (result.mismatches.length > 0) {
3809
+ console.log(` field mismatches (${result.mismatches.length}):`);
3810
+ for (const m of result.mismatches.slice(0, 10)) {
3811
+ console.log(` - record ${m.ordinal} (${m.key}) field ${m.field}: expected ${JSON.stringify(m.expected)}, got ${JSON.stringify(m.got)}`);
3812
+ }
3813
+ if (result.mismatches.length > 10)
3814
+ console.log(` ... ${result.mismatches.length - 10} more`);
3815
+ }
3816
+ console.log(`\n Intermediate export at: ${result.tmpExportPath}`);
3817
+ process.exit(1);
3818
+ }
3819
+ catch (err) {
3820
+ if (err instanceof BridgeRuntimeError) {
3821
+ printBridgeError(err);
3822
+ process.exit(1);
3823
+ }
3824
+ console.error(`Bridge test failed: ${err?.message ?? err}`);
3825
+ process.exit(1);
3826
+ }
3827
+ });
3828
+ function printBridgeError(err) {
3829
+ // Pretty-print BridgeRuntimeError as the structured shape from §10 of the
3830
+ // spec, plus a one-line human summary so the operator gets both.
3831
+ const detail = err?.detail;
3832
+ if (detail && typeof detail === "object") {
3833
+ console.error(`Bridge error: ${detail.hint ?? err.message}`);
3834
+ console.error(JSON.stringify(detail, null, 2));
3835
+ }
3836
+ else {
3837
+ console.error(`Bridge error: ${err.message ?? String(err)}`);
3838
+ }
3839
+ }
2846
3840
  // ─── flair backup ────────────────────────────────────────────────────────────
2847
3841
  program
2848
3842
  .command("backup")
@@ -3252,110 +4246,6 @@ program
3252
4246
  console.log(`Souls: ${(data.souls ?? []).length}`);
3253
4247
  }
3254
4248
  });
3255
- // ─── flair migrate-keys ───────────────────────────────────────────────────────
3256
- program
3257
- .command("migrate-keys")
3258
- .description("Migrate agent keys from old path (~/.tps/secrets/flair/) to ~/.flair/keys/")
3259
- .option("--from <dir>", "Old keys directory", join(homedir(), ".tps", "secrets", "flair"))
3260
- .option("--to <dir>", "New keys directory", defaultKeysDir())
3261
- .option("--dry-run", "Show what would be migrated without copying")
3262
- .option("--port <port>", "Harper HTTP port")
3263
- .option("--ops-port <port>", "Harper operations API port")
3264
- .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
3265
- .action(async (opts) => {
3266
- const fromDir = opts.from;
3267
- const toDir = opts.to;
3268
- const dryRun = opts.dryRun ?? false;
3269
- const opsPort = resolveOpsPort(opts);
3270
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
3271
- if (!existsSync(fromDir)) {
3272
- console.log(`Old keys directory not found: ${fromDir}`);
3273
- console.log("Nothing to migrate.");
3274
- process.exit(0);
3275
- }
3276
- // Discover legacy keys: <id>-priv.key pattern
3277
- const { readdirSync, unlinkSync } = await import("node:fs");
3278
- const files = readdirSync(fromDir);
3279
- const keyFiles = files.filter((f) => f.endsWith("-priv.key"));
3280
- if (keyFiles.length === 0) {
3281
- console.log(`No legacy key files found in ${fromDir}`);
3282
- process.exit(0);
3283
- }
3284
- console.log(`Found ${keyFiles.length} legacy key file(s) in ${fromDir}:`);
3285
- let migrated = 0;
3286
- let skipped = 0;
3287
- for (const file of keyFiles) {
3288
- const agentId = file.replace("-priv.key", "");
3289
- const srcPath = join(fromDir, file);
3290
- const destPath = join(toDir, `${agentId}.key`);
3291
- if (existsSync(destPath)) {
3292
- console.log(` skip: ${agentId} — already exists at ${destPath}`);
3293
- skipped++;
3294
- continue;
3295
- }
3296
- if (dryRun) {
3297
- console.log(` would migrate: ${srcPath} → ${destPath}`);
3298
- migrated++;
3299
- continue;
3300
- }
3301
- mkdirSync(toDir, { recursive: true });
3302
- const keyData = readFileSync(srcPath);
3303
- writeFileSync(destPath, keyData);
3304
- chmodSync(destPath, 0o600);
3305
- console.log(` migrated: ${agentId} → ${destPath}`);
3306
- migrated++;
3307
- }
3308
- if (dryRun) {
3309
- console.log(`\n🔍 Dry run: ${migrated} key(s) would be migrated, ${skipped} skipped`);
3310
- }
3311
- else {
3312
- console.log(`\n✅ Migration complete: ${migrated} migrated, ${skipped} skipped`);
3313
- if (migrated > 0) {
3314
- console.log(`\nOld keys preserved at ${fromDir} (delete manually when confirmed working).`);
3315
- // Optionally verify keys match Flair records
3316
- if (adminPass) {
3317
- console.log("\nVerifying migrated keys against Flair...");
3318
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
3319
- for (const file of keyFiles) {
3320
- const agentId = file.replace("-priv.key", "");
3321
- const destPath = join(toDir, `${agentId}.key`);
3322
- if (!existsSync(destPath))
3323
- continue;
3324
- try {
3325
- const keyB64 = readFileSync(destPath, "utf-8").trim();
3326
- const seed = Buffer.from(keyB64, "base64");
3327
- const kp = nacl.sign.keyPair.fromSeed(new Uint8Array(seed));
3328
- const localPub = b64url(kp.publicKey);
3329
- const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
3330
- method: "POST",
3331
- headers: { "Content-Type": "application/json", Authorization: auth },
3332
- body: JSON.stringify({ operation: "search_by_value", database: "flair", table: "Agent", search_attribute: "id", search_value: agentId, get_attributes: ["id", "publicKey"] }),
3333
- signal: AbortSignal.timeout(10_000),
3334
- });
3335
- if (res.ok) {
3336
- const agents = await res.json();
3337
- if (Array.isArray(agents) && agents.length > 0) {
3338
- const remotePub = agents[0].publicKey;
3339
- if (remotePub === localPub) {
3340
- console.log(` ✅ ${agentId}: key matches Flair`);
3341
- }
3342
- else {
3343
- console.log(` ⚠️ ${agentId}: key MISMATCH — local key doesn't match Flair record`);
3344
- }
3345
- }
3346
- else {
3347
- console.log(` ⚠️ ${agentId}: not found in Flair`);
3348
- }
3349
- }
3350
- }
3351
- catch (err) {
3352
- console.log(` ⚠️ ${agentId}: verification failed — ${err.message}`);
3353
- }
3354
- }
3355
- }
3356
- }
3357
- }
3358
- });
3359
4249
  // Run CLI only when this is the entry point (not when imported for testing)
3360
4250
  if (import.meta.main) {
3361
4251
  await program.parseAsync();