@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/README.md +3 -0
- package/dist/bridges/builtins/agentic-stack.js +58 -0
- package/dist/bridges/builtins/index.js +36 -0
- package/dist/bridges/discover.js +195 -0
- package/dist/bridges/runtime/context.js +59 -0
- package/dist/bridges/runtime/execute.js +92 -0
- package/dist/bridges/runtime/export-runner.js +105 -0
- package/dist/bridges/runtime/formats.js +136 -0
- package/dist/bridges/runtime/import-runner.js +107 -0
- package/dist/bridges/runtime/load-descriptor.js +46 -0
- package/dist/bridges/runtime/mapper.js +139 -0
- package/dist/bridges/runtime/predicate.js +122 -0
- package/dist/bridges/runtime/writers.js +130 -0
- package/dist/bridges/runtime/yaml-loader.js +148 -0
- package/dist/bridges/scaffold.js +224 -0
- package/dist/bridges/types.js +30 -0
- package/dist/cli.js +1019 -223
- package/dist/deploy.js +161 -0
- package/dist/resources/MemoryBootstrap.js +16 -6
- package/dist/resources/MemoryConsolidate.js +13 -4
- package/dist/resources/MemoryReflect.js +14 -5
- package/dist/resources/ObservationCenter.js +19 -0
- package/dist/resources/SemanticSearch.js +22 -11
- package/dist/resources/auth-middleware.js +6 -0
- package/dist/resources/health.js +443 -14
- package/package.json +7 -7
- package/ui/observation-center.html +91 -0
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}/
|
|
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
|
-
|
|
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
|
|
539
|
-
// or when stdin is not a TTY (CI, scripts,
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1797
|
-
.
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1898
|
-
|
|
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(`
|
|
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 (
|
|
1907
|
-
|
|
1908
|
-
|
|
2245
|
+
if (!healthy) {
|
|
2246
|
+
console.log("🔴 unreachable");
|
|
2247
|
+
process.exit(1);
|
|
1909
2248
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
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
|
-
|
|
1914
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
2402
|
-
|
|
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
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
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>")
|
|
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
|
|
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>")
|
|
2703
|
-
.
|
|
2704
|
-
|
|
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
|
-
|
|
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();
|