@tpsdev-ai/flair 0.14.0 → 0.16.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 +66 -9
- package/dist/cli-shim.cjs +66 -0
- package/dist/cli.js +669 -417
- package/dist/fabric-upgrade.js +367 -0
- package/dist/install/clients.js +132 -81
- package/dist/resources/A2AAdapter.js +31 -5
- package/dist/resources/OrgEvent.js +12 -2
- package/dist/resources/Presence.js +3 -9
- package/dist/resources/SemanticSearch.js +124 -2
- package/dist/resources/WorkspaceState.js +12 -2
- package/dist/resources/agent-auth.js +3 -10
- package/dist/resources/auth-middleware.js +3 -10
- package/dist/resources/b64.js +40 -0
- package/dist/resources/bm25-filter.js +84 -0
- package/dist/resources/bm25.js +130 -0
- package/dist/resources/embeddings-provider.js +41 -6
- package/package.json +10 -10
package/dist/cli.js
CHANGED
|
@@ -4,14 +4,15 @@ import nacl from "tweetnacl";
|
|
|
4
4
|
import { load as parseYaml } from "js-yaml";
|
|
5
5
|
import * as render from "./render.js";
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
|
|
7
|
-
import { homedir,
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join, resolve, sep, dirname } from "node:path";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
|
|
11
11
|
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
12
12
|
import { keystore } from "./keystore.js";
|
|
13
13
|
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
|
|
14
|
-
import {
|
|
14
|
+
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
15
|
+
import { detectClients, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
15
16
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
16
17
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
17
18
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -446,6 +447,117 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
|
|
|
446
447
|
}
|
|
447
448
|
throw new Error(`Harper at port ${httpPort} did not respond within ${timeoutMs}ms (${attempt} attempts)`);
|
|
448
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* Verify that semantic search ACTUALLY works by storing a memory with a
|
|
452
|
+
* distinctive phrase and searching for a PARAPHRASE (different words, same
|
|
453
|
+
* meaning). If embeddings are loaded, the paraphrase recovers the memory by
|
|
454
|
+
* meaning with a genuine semantic score. If embeddings are NOT loaded,
|
|
455
|
+
* SemanticSearch falls back to keyword-only scan: the paraphrase shares no
|
|
456
|
+
* keywords with the stored content, so the memory is NOT recovered (or only via
|
|
457
|
+
* the `_warning` keyword-fallback marker) → "degraded".
|
|
458
|
+
*
|
|
459
|
+
* The probe is authenticated as a real agent (Ed25519) because SemanticSearch
|
|
460
|
+
* rejects anonymous callers (401) and per-agent scoping requires it. We pick the
|
|
461
|
+
* given agentId, else FLAIR_AGENT_ID, else the first `.key` in keysDir.
|
|
462
|
+
*
|
|
463
|
+
* Exported so the init smoke test and unit tests can reuse the exact same gate.
|
|
464
|
+
*/
|
|
465
|
+
export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
466
|
+
// Resolve an agent + key to sign with.
|
|
467
|
+
let agentId = agentIdOpt || process.env.FLAIR_AGENT_ID || undefined;
|
|
468
|
+
if (!agentId) {
|
|
469
|
+
try {
|
|
470
|
+
const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
|
|
471
|
+
if (keyFiles.length > 0)
|
|
472
|
+
agentId = keyFiles[0].replace(/\.key$/, "");
|
|
473
|
+
}
|
|
474
|
+
catch { /* keysDir missing */ }
|
|
475
|
+
}
|
|
476
|
+
if (!agentId) {
|
|
477
|
+
return { state: "skipped", detail: "no agent id or key found" };
|
|
478
|
+
}
|
|
479
|
+
// Find the signing key. Prefer the standard locations (resolveKeyPath), but
|
|
480
|
+
// fall back to the keysDir we were handed — `flair init` keys live there and
|
|
481
|
+
// it may not be a standard location (e.g. --keys-dir, tests).
|
|
482
|
+
let keyPath = resolveKeyPath(agentId);
|
|
483
|
+
if (!keyPath) {
|
|
484
|
+
const candidate = join(keysDir, `${agentId}.key`);
|
|
485
|
+
if (existsSync(candidate))
|
|
486
|
+
keyPath = candidate;
|
|
487
|
+
}
|
|
488
|
+
if (!keyPath) {
|
|
489
|
+
return { state: "skipped", detail: `no private key for agent '${agentId}'` };
|
|
490
|
+
}
|
|
491
|
+
// Distinctive content vs. a PARAPHRASE query with deliberately ZERO shared
|
|
492
|
+
// content words. If the search recovers the memory it can ONLY be by meaning.
|
|
493
|
+
// content: "The feline predator silently stalked its unsuspecting rodent quarry at dusk."
|
|
494
|
+
// query: "a cat hunting a mouse in the evening"
|
|
495
|
+
// No word in the query appears in the content (cat≠feline, mouse≠rodent,
|
|
496
|
+
// hunting≠stalked, evening≠dusk), so a keyword scan returns nothing.
|
|
497
|
+
const marker = `flair-doctor-embed-check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
498
|
+
const id = marker;
|
|
499
|
+
const content = `The feline predator silently stalked its unsuspecting rodent quarry at dusk. [${marker}]`;
|
|
500
|
+
const paraphrase = "a cat hunting a mouse in the evening";
|
|
501
|
+
let stored = false;
|
|
502
|
+
try {
|
|
503
|
+
// Write the test memory (ephemeral so it's never durable). PUT /Memory/<id>.
|
|
504
|
+
const writeRes = await authFetch(baseUrl, agentId, keyPath, "PUT", `/Memory/${id}`, {
|
|
505
|
+
id, agentId, content, durability: "ephemeral", createdAt: new Date().toISOString(),
|
|
506
|
+
});
|
|
507
|
+
if (!writeRes.ok && writeRes.status !== 204) {
|
|
508
|
+
const text = await writeRes.text().catch(() => "");
|
|
509
|
+
return { state: "skipped", detail: `could not write probe memory: HTTP ${writeRes.status} ${text.slice(0, 80)}` };
|
|
510
|
+
}
|
|
511
|
+
stored = true;
|
|
512
|
+
// Allow the HNSW index to catch up before searching.
|
|
513
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
514
|
+
// Search by PARAPHRASE. scoring: "raw" so we read the unweighted semantic
|
|
515
|
+
// similarity (_rawScore) directly, without recency/durability composites
|
|
516
|
+
// muddying the keyword-vs-semantic distinction.
|
|
517
|
+
const searchRes = await authFetch(baseUrl, agentId, keyPath, "POST", "/SemanticSearch", {
|
|
518
|
+
agentId, q: paraphrase, limit: 10, scoring: "raw",
|
|
519
|
+
});
|
|
520
|
+
if (!searchRes.ok) {
|
|
521
|
+
const text = await searchRes.text().catch(() => "");
|
|
522
|
+
return { state: "skipped", detail: `SemanticSearch failed: HTTP ${searchRes.status} ${text.slice(0, 80)}` };
|
|
523
|
+
}
|
|
524
|
+
const data = await searchRes.json();
|
|
525
|
+
// The server sets _warning ONLY when getMode() === "none" — i.e. the
|
|
526
|
+
// embedding engine failed to init and the search ran keyword-only. That is
|
|
527
|
+
// the unambiguous "embeddings not loaded" signal.
|
|
528
|
+
if (data._warning) {
|
|
529
|
+
return { state: "degraded", detail: data._warning };
|
|
530
|
+
}
|
|
531
|
+
const results = data.results ?? [];
|
|
532
|
+
const hit = results.find((r) => r.id === id);
|
|
533
|
+
if (!hit) {
|
|
534
|
+
// The paraphrase shares no keywords with the content, so a keyword-only
|
|
535
|
+
// fallback can't find it. Missing the memory == recall-by-meaning is dead.
|
|
536
|
+
return { state: "degraded", detail: "paraphrase did not recall the probe memory (keyword-only fallback active)" };
|
|
537
|
+
}
|
|
538
|
+
// A genuine semantic hit has a positive similarity score. The keyword bonus
|
|
539
|
+
// is +0.05; since there is no keyword overlap here, any score above that
|
|
540
|
+
// floor can only come from vector similarity. Require a real semantic score.
|
|
541
|
+
const score = typeof hit._rawScore === "number" ? hit._rawScore : (hit._score ?? 0);
|
|
542
|
+
if (score <= 0.05) {
|
|
543
|
+
return { state: "degraded", detail: `probe recalled with non-semantic score ${score} (keyword/zero)` };
|
|
544
|
+
}
|
|
545
|
+
return { state: "ok", score };
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
549
|
+
return { state: "skipped", detail: `probe error: ${message.slice(0, 100)}` };
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
// Best-effort cleanup of the ephemeral probe memory.
|
|
553
|
+
if (stored) {
|
|
554
|
+
try {
|
|
555
|
+
await authFetch(baseUrl, agentId, keyPath, "DELETE", `/Memory/${id}`);
|
|
556
|
+
}
|
|
557
|
+
catch { /* leave the ephemeral row; it'll age out */ }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
449
561
|
// Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
|
|
450
562
|
// Used during restart to confirm the old Harper process actually exited before
|
|
451
563
|
// we start polling /Health — otherwise the still-shutting-down old process can
|
|
@@ -491,11 +603,29 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
|
|
|
491
603
|
// Send Basic auth whenever the caller passed an adminPass. The caller decides
|
|
492
604
|
// when to omit it (e.g., local target with authorizeLocal=true).
|
|
493
605
|
const auth = adminPass !== undefined ? Buffer.from(`${adminUser}:${adminPass}`).toString("base64") : undefined;
|
|
606
|
+
// The ops-API insert bypasses the Agent resource layer, so Agent.post()'s
|
|
607
|
+
// 1.0 Principal defaults (kind/status/displayName/admin/defaultTrustTier/type)
|
|
608
|
+
// never run. Without them a remote-seeded agent lands kind=null, status=null
|
|
609
|
+
// and is invisible to roster/presence/Office-Space queries that filter on
|
|
610
|
+
// status='active' or kind='agent' (#521). Mirror Agent.post() exactly here.
|
|
611
|
+
const now = new Date().toISOString();
|
|
494
612
|
const body = {
|
|
495
613
|
operation: "insert",
|
|
496
614
|
database: "flair",
|
|
497
615
|
table: "Agent",
|
|
498
|
-
records: [{
|
|
616
|
+
records: [{
|
|
617
|
+
id: agentId,
|
|
618
|
+
name: agentId,
|
|
619
|
+
type: "agent",
|
|
620
|
+
kind: "agent",
|
|
621
|
+
status: "active",
|
|
622
|
+
displayName: agentId,
|
|
623
|
+
admin: false,
|
|
624
|
+
defaultTrustTier: "unverified",
|
|
625
|
+
publicKey: pubKeyB64url,
|
|
626
|
+
createdAt: now,
|
|
627
|
+
updatedAt: now,
|
|
628
|
+
}],
|
|
499
629
|
};
|
|
500
630
|
const res = await fetch(url, {
|
|
501
631
|
method: "POST",
|
|
@@ -1212,8 +1342,9 @@ program.name("flair").version(__pkgVersion, "-v, --version");
|
|
|
1212
1342
|
// ─── flair init ──────────────────────────────────────────────────────────────
|
|
1213
1343
|
program
|
|
1214
1344
|
.command("init")
|
|
1215
|
-
.description("
|
|
1345
|
+
.description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
|
|
1216
1346
|
.option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
|
|
1347
|
+
.option("--agent <id>", "Alias for --agent-id")
|
|
1217
1348
|
.option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
|
|
1218
1349
|
.option("--ops-port <port>", "Harper operations API port")
|
|
1219
1350
|
.option("--admin-pass <pass>", "Admin password (generated if omitted)")
|
|
@@ -1222,6 +1353,9 @@ program
|
|
|
1222
1353
|
.option("--data-dir <dir>", "Harper data directory")
|
|
1223
1354
|
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
1224
1355
|
.option("--skip-soul", "Skip interactive personality setup")
|
|
1356
|
+
.option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
|
|
1357
|
+
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
1358
|
+
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
1225
1359
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
1226
1360
|
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
1227
1361
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
@@ -1230,7 +1364,7 @@ program
|
|
|
1230
1364
|
.option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
|
|
1231
1365
|
.option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
|
|
1232
1366
|
.action(async (opts) => {
|
|
1233
|
-
const agentId = opts.agentId;
|
|
1367
|
+
const agentId = opts.agentId ?? opts.agent;
|
|
1234
1368
|
const target = resolveTarget(opts);
|
|
1235
1369
|
const opsTarget = resolveOpsTarget(opts);
|
|
1236
1370
|
// ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
|
|
@@ -1407,11 +1541,26 @@ program
|
|
|
1407
1541
|
}
|
|
1408
1542
|
return;
|
|
1409
1543
|
}
|
|
1410
|
-
// ── Local init (
|
|
1544
|
+
// ── Local init (full one-command setup) ──
|
|
1411
1545
|
const httpPort = resolveHttpPort(opts);
|
|
1412
1546
|
const opsPort = resolveOpsPort(opts);
|
|
1413
1547
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
1414
1548
|
const dataDir = opts.dataDir ?? defaultDataDir();
|
|
1549
|
+
// Resolve MCP client selection (union of init's auto-wire + the multi-client
|
|
1550
|
+
// detection/wiring that the front-door command provides). `--no-mcp` sets
|
|
1551
|
+
// opts.mcp === false (commander negates the flag). Validate an explicit
|
|
1552
|
+
// --client up front so a typo fails before Harper is touched.
|
|
1553
|
+
const clientOpt = opts.client;
|
|
1554
|
+
const noMcp = opts.mcp === false;
|
|
1555
|
+
const selectedClients = [];
|
|
1556
|
+
if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
|
|
1557
|
+
const valid = ["claude-code", "codex", "gemini", "cursor"];
|
|
1558
|
+
if (!valid.includes(clientOpt)) {
|
|
1559
|
+
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
|
|
1560
|
+
process.exit(1);
|
|
1561
|
+
}
|
|
1562
|
+
selectedClients.push(clientOpt);
|
|
1563
|
+
}
|
|
1415
1564
|
// Admin password: determine from opts, env, or generate
|
|
1416
1565
|
// Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
|
|
1417
1566
|
let adminPass;
|
|
@@ -1669,6 +1818,26 @@ program
|
|
|
1669
1818
|
if (!verifyRes.ok)
|
|
1670
1819
|
throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
|
|
1671
1820
|
console.log("Ed25519 auth verified ✓");
|
|
1821
|
+
// Verify semantic search ACTUALLY works (real embed→paraphrase-search
|
|
1822
|
+
// round-trip). A clean-VM dogfood found semantic search dead out of the box
|
|
1823
|
+
// (sudo/root-owned install can't write the embeddings models symlink →
|
|
1824
|
+
// EACCES) while init reported success. Never report a clean init when
|
|
1825
|
+
// recall-by-meaning is broken. Skipped paths (no key yet) are non-fatal.
|
|
1826
|
+
console.log("Verifying semantic search...");
|
|
1827
|
+
const embedCheck = await verifySemanticSearch(httpUrl, agentId, keysDir);
|
|
1828
|
+
if (embedCheck.state === "ok") {
|
|
1829
|
+
console.log(`Semantic search operational ✓ ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${embedCheck.score.toFixed(2)})`)}`);
|
|
1830
|
+
}
|
|
1831
|
+
else if (embedCheck.state === "degraded") {
|
|
1832
|
+
// LOUD — embeddings not loaded. Same message class as `flair doctor`.
|
|
1833
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Semantic search DEGRADED")} — embeddings not loaded; recall-by-meaning will NOT work.`);
|
|
1834
|
+
console.log(` ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
|
|
1835
|
+
console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
|
|
1836
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: install without sudo (see README Quick Start), then:")} flair restart && flair doctor`);
|
|
1837
|
+
}
|
|
1838
|
+
else {
|
|
1839
|
+
console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
|
|
1840
|
+
}
|
|
1672
1841
|
// Output — admin password printed once, never written to disk
|
|
1673
1842
|
console.log("\n✅ Flair initialized successfully");
|
|
1674
1843
|
console.log(` Agent ID: ${agentId}`);
|
|
@@ -1728,38 +1897,164 @@ program
|
|
|
1728
1897
|
}
|
|
1729
1898
|
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
1730
1899
|
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
1731
|
-
//
|
|
1732
|
-
|
|
1900
|
+
// ── MCP client wiring ────────────────────────────────────────────────
|
|
1901
|
+
// The full one-command front door: detect installed MCP clients and wire
|
|
1902
|
+
// each to the zero-install `npx -y @tpsdev-ai/flair-mcp` server. Claude
|
|
1903
|
+
// Code is auto-wired into ~/.claude.json (the only client the CLI can
|
|
1904
|
+
// safely modify); other clients get copy-paste snippets. `--no-mcp`
|
|
1905
|
+
// skips wiring entirely; `--client <name>` targets one client; the
|
|
1906
|
+
// default (no flag) wires every detected client.
|
|
1733
1907
|
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
console.log(`\n MCP config already set in ~/.claude.json ✓`);
|
|
1908
|
+
const wiringResults = [];
|
|
1909
|
+
if (!noMcp && clientOpt !== "none") {
|
|
1910
|
+
// Determine which clients to wire.
|
|
1911
|
+
let clients = detectClients();
|
|
1912
|
+
if (selectedClients.length > 0) {
|
|
1913
|
+
clients = clients.filter(c => selectedClients.includes(c.id));
|
|
1914
|
+
}
|
|
1915
|
+
const detected = clients.filter(c => c.detected);
|
|
1916
|
+
if (!clientOpt) {
|
|
1917
|
+
if (detected.length === 0) {
|
|
1918
|
+
console.log("\n No MCP clients detected. Run with --client <name> to wire a specific client.");
|
|
1746
1919
|
}
|
|
1747
1920
|
else {
|
|
1748
|
-
|
|
1749
|
-
claudeJson.mcpServers.flair = flairMcpConfig;
|
|
1750
|
-
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
1751
|
-
console.log(`\n MCP config written to ~/.claude.json ✓`);
|
|
1752
|
-
console.log(` Restart Claude Code to pick up the new config.`);
|
|
1921
|
+
console.log(`\n Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
|
|
1753
1922
|
}
|
|
1754
1923
|
}
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1924
|
+
const toWire = clientOpt === "all"
|
|
1925
|
+
? clients.filter(c => c.detected).map(c => c.id)
|
|
1926
|
+
: selectedClients.length > 0
|
|
1927
|
+
? selectedClients
|
|
1928
|
+
: clients.filter(c => c.detected).map(c => c.id);
|
|
1929
|
+
for (const clientId of toWire) {
|
|
1930
|
+
if (clientId === "claude-code") {
|
|
1931
|
+
// Claude Code gets real auto-wiring into ~/.claude.json (zero-install
|
|
1932
|
+
// npx form; matches the snippets everywhere else). Other clients only
|
|
1933
|
+
// get printed instructions — the CLI can't safely edit their configs.
|
|
1934
|
+
const claudeJsonPath = join(homedir(), ".claude.json");
|
|
1935
|
+
const flairMcpConfig = {
|
|
1936
|
+
type: "stdio",
|
|
1937
|
+
command: "npx",
|
|
1938
|
+
args: ["-y", "@tpsdev-ai/flair-mcp"],
|
|
1939
|
+
env: mcpEnv,
|
|
1940
|
+
};
|
|
1941
|
+
try {
|
|
1942
|
+
if (existsSync(claudeJsonPath)) {
|
|
1943
|
+
const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
|
|
1944
|
+
const existing = claudeJson.mcpServers?.flair;
|
|
1945
|
+
if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
|
|
1946
|
+
console.log(` ✓ Claude Code already wired in ~/.claude.json`);
|
|
1947
|
+
wiringResults.push({ client: "claude-code", message: "already wired" });
|
|
1948
|
+
}
|
|
1949
|
+
else {
|
|
1950
|
+
claudeJson.mcpServers = claudeJson.mcpServers || {};
|
|
1951
|
+
claudeJson.mcpServers.flair = flairMcpConfig;
|
|
1952
|
+
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
1953
|
+
console.log(` ✓ Claude Code wired in ~/.claude.json (restart Claude Code to pick it up)`);
|
|
1954
|
+
wiringResults.push({ client: "claude-code", message: "wired ~/.claude.json" });
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
else {
|
|
1958
|
+
console.log(` MCP config (add to ~/.claude.json):`);
|
|
1959
|
+
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
1960
|
+
wiringResults.push({ client: "claude-code", message: "snippet printed (no ~/.claude.json)" });
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
catch {
|
|
1964
|
+
console.log(` MCP config (add manually to ~/.claude.json):`);
|
|
1965
|
+
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
1966
|
+
wiringResults.push({ client: "claude-code", message: "snippet printed" });
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
else {
|
|
1970
|
+
let result;
|
|
1971
|
+
switch (clientId) {
|
|
1972
|
+
case "codex":
|
|
1973
|
+
result = wireCodex(mcpEnv);
|
|
1974
|
+
break;
|
|
1975
|
+
case "gemini":
|
|
1976
|
+
result = wireGemini(mcpEnv);
|
|
1977
|
+
break;
|
|
1978
|
+
case "cursor":
|
|
1979
|
+
result = wireCursor(mcpEnv);
|
|
1980
|
+
break;
|
|
1981
|
+
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
1982
|
+
}
|
|
1983
|
+
wiringResults.push({ client: clientId, message: result.message });
|
|
1984
|
+
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
1985
|
+
}
|
|
1758
1986
|
}
|
|
1759
1987
|
}
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1988
|
+
// ── Smoke-test the MCP server ────────────────────────────────────────
|
|
1989
|
+
// Launch flair-mcp and confirm it answers a JSON-RPC initialize over
|
|
1990
|
+
// stdio. Best-effort: failures warn but never fail the command. Skipped
|
|
1991
|
+
// with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
|
|
1992
|
+
if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
|
|
1993
|
+
console.log("\n Smoke-testing MCP server...");
|
|
1994
|
+
try {
|
|
1995
|
+
const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
|
|
1996
|
+
env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
|
|
1997
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1998
|
+
});
|
|
1999
|
+
const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-init", version: "1.0.0" } } });
|
|
2000
|
+
mcpProc.stdin.write(initMsg + "\n");
|
|
2001
|
+
mcpProc.stdin.end();
|
|
2002
|
+
let stdout = "";
|
|
2003
|
+
mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
2004
|
+
// A single timer drives the timeout AND cleanup. It MUST be cleared on
|
|
2005
|
+
// settle — an un-cleared setTimeout is a live handle that keeps Node's
|
|
2006
|
+
// event loop alive (the ~60s phantom hang after `flair init` printed
|
|
2007
|
+
// success: the smoke timer + the lingering npx child both pinned the
|
|
2008
|
+
// loop). We clear it on every exit path below.
|
|
2009
|
+
let smokeTimer = null;
|
|
2010
|
+
await new Promise((resolve, reject) => {
|
|
2011
|
+
const settle = (fn) => {
|
|
2012
|
+
if (smokeTimer) {
|
|
2013
|
+
clearTimeout(smokeTimer);
|
|
2014
|
+
smokeTimer = null;
|
|
2015
|
+
}
|
|
2016
|
+
fn();
|
|
2017
|
+
};
|
|
2018
|
+
mcpProc.on("exit", (code) => {
|
|
2019
|
+
settle(() => {
|
|
2020
|
+
if (code === 0 && stdout.length > 0)
|
|
2021
|
+
resolve();
|
|
2022
|
+
else
|
|
2023
|
+
reject(new Error(`MCP server exited with code ${code}`));
|
|
2024
|
+
});
|
|
2025
|
+
});
|
|
2026
|
+
mcpProc.on("error", (err) => settle(() => reject(err)));
|
|
2027
|
+
smokeTimer = setTimeout(() => settle(() => { mcpProc.kill("SIGKILL"); reject(new Error("MCP smoke test timed out")); }), 15_000);
|
|
2028
|
+
});
|
|
2029
|
+
try {
|
|
2030
|
+
const lines = stdout.split("\n").filter(l => l.trim());
|
|
2031
|
+
for (const line of lines) {
|
|
2032
|
+
const parsed = JSON.parse(line);
|
|
2033
|
+
if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
|
|
2034
|
+
console.log(" ✓ MCP server responded");
|
|
2035
|
+
break;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
catch {
|
|
2040
|
+
console.log(" ⚠ MCP server responded but response could not be parsed");
|
|
2041
|
+
}
|
|
2042
|
+
finally {
|
|
2043
|
+
// Reap the child even on the resolve path: the MCP server exits on
|
|
2044
|
+
// stdin close, but the `npx` wrapper can linger holding the loop.
|
|
2045
|
+
// SIGKILL is safe — we already have the response we need.
|
|
2046
|
+
try {
|
|
2047
|
+
if (mcpProc.exitCode === null)
|
|
2048
|
+
mcpProc.kill("SIGKILL");
|
|
2049
|
+
}
|
|
2050
|
+
catch { /* already gone */ }
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
catch (err) {
|
|
2054
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2055
|
+
console.log(` ⚠ MCP smoke test failed: ${message}`);
|
|
2056
|
+
console.log(" Use --skip-smoke to bypass.");
|
|
2057
|
+
}
|
|
1763
2058
|
}
|
|
1764
2059
|
}
|
|
1765
2060
|
else {
|
|
@@ -1783,354 +2078,16 @@ program
|
|
|
1783
2078
|
}
|
|
1784
2079
|
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
1785
2080
|
}
|
|
1786
|
-
|
|
1787
|
-
//
|
|
1788
|
-
|
|
1789
|
-
.
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
.
|
|
1795
|
-
.
|
|
1796
|
-
.option("--data-dir <dir>", "Harper data directory")
|
|
1797
|
-
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
1798
|
-
.option("--skip-smoke", "Skip MCP smoke test")
|
|
1799
|
-
.action(async (opts) => {
|
|
1800
|
-
const httpPort = resolveHttpPort(opts);
|
|
1801
|
-
const opsPort = resolveOpsPort(opts);
|
|
1802
|
-
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
1803
|
-
const dataDir = opts.dataDir ?? defaultDataDir();
|
|
1804
|
-
// Resolve client selection
|
|
1805
|
-
const clientOpt = opts.client;
|
|
1806
|
-
const noMcp = opts.noMcp === true;
|
|
1807
|
-
const selectedClients = [];
|
|
1808
|
-
if (clientOpt === "none" || noMcp) {
|
|
1809
|
-
// Skip MCP entirely — just init + agent
|
|
1810
|
-
}
|
|
1811
|
-
else if (clientOpt === "all") {
|
|
1812
|
-
// Wire all detected clients
|
|
1813
|
-
}
|
|
1814
|
-
else if (clientOpt) {
|
|
1815
|
-
// Wire a specific client
|
|
1816
|
-
const valid = ["claude-code", "codex", "gemini", "cursor"];
|
|
1817
|
-
if (!valid.includes(clientOpt)) {
|
|
1818
|
-
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
|
|
1819
|
-
process.exit(1);
|
|
1820
|
-
}
|
|
1821
|
-
selectedClients.push(clientOpt);
|
|
1822
|
-
}
|
|
1823
|
-
// If no --client flag: interactive detection later
|
|
1824
|
-
// ── Step 1: Ensure Flair is initialized ──
|
|
1825
|
-
let alreadyInitialized = false;
|
|
1826
|
-
let alreadyRunning = false;
|
|
1827
|
-
let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1828
|
-
const adminUser = DEFAULT_ADMIN_USER;
|
|
1829
|
-
// Check if Flair is already initialized (config with port exists)
|
|
1830
|
-
try {
|
|
1831
|
-
const cp = configPath();
|
|
1832
|
-
if (existsSync(cp)) {
|
|
1833
|
-
const yaml = readFileSync(cp, "utf-8");
|
|
1834
|
-
if (yaml.match(/port:\s*\d+/)) {
|
|
1835
|
-
alreadyInitialized = true;
|
|
1836
|
-
console.log("Flair already initialized — skipping init.");
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
catch { /* not initialized */ }
|
|
1841
|
-
if (!alreadyInitialized) {
|
|
1842
|
-
const major = parseInt(process.version.slice(1), 10);
|
|
1843
|
-
if (major < 18)
|
|
1844
|
-
throw new Error(`Node.js >= 18 required (found ${process.version})`);
|
|
1845
|
-
// Only generate a password if none provided via env (fresh install)
|
|
1846
|
-
// If we generate, write it atomically to ~/.flair/admin-pass
|
|
1847
|
-
let adminPassGenerated = false;
|
|
1848
|
-
if (!adminPass) {
|
|
1849
|
-
adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
|
|
1850
|
-
adminPassGenerated = true;
|
|
1851
|
-
// Atomic write: create temp file in same dir, then rename
|
|
1852
|
-
const flairDir = join(homedir(), ".flair");
|
|
1853
|
-
mkdirSync(flairDir, { recursive: true });
|
|
1854
|
-
const adminPassPath = join(flairDir, "admin-pass");
|
|
1855
|
-
const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
|
|
1856
|
-
const finalTempPath = join(tempPath, "admin-pass");
|
|
1857
|
-
try {
|
|
1858
|
-
writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
|
|
1859
|
-
renameSync(finalTempPath, adminPassPath);
|
|
1860
|
-
rmSync(tempPath, { recursive: true, force: true });
|
|
1861
|
-
}
|
|
1862
|
-
catch (err) {
|
|
1863
|
-
// Clean up temp dir on failure
|
|
1864
|
-
try {
|
|
1865
|
-
rmSync(tempPath, { recursive: true, force: true });
|
|
1866
|
-
}
|
|
1867
|
-
catch { }
|
|
1868
|
-
throw err;
|
|
1869
|
-
}
|
|
1870
|
-
console.log(`Admin password saved to: ${adminPassPath}`);
|
|
1871
|
-
}
|
|
1872
|
-
// Check if Harper is already running on this port
|
|
1873
|
-
try {
|
|
1874
|
-
const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
|
|
1875
|
-
if (res.status > 0) {
|
|
1876
|
-
alreadyRunning = true;
|
|
1877
|
-
console.log(`Harper already running on port ${httpPort} — skipping start`);
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
catch { /* not running */ }
|
|
1881
|
-
if (!alreadyRunning) {
|
|
1882
|
-
const bin = harperBin();
|
|
1883
|
-
if (!bin)
|
|
1884
|
-
throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
|
|
1885
|
-
mkdirSync(dataDir, { recursive: true });
|
|
1886
|
-
const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
|
|
1887
|
-
const harperSetConfig = JSON.stringify({
|
|
1888
|
-
rootPath: dataDir,
|
|
1889
|
-
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
1890
|
-
operationsApi: { network: { port: opsPort, cors: true }, domainSocket: join(dataDir, "operations-server") },
|
|
1891
|
-
mqtt: { network: { port: null }, webSocket: false },
|
|
1892
|
-
localStudio: { enabled: false },
|
|
1893
|
-
authentication: { authorizeLocal: true, enableSessions: true },
|
|
1894
|
-
});
|
|
1895
|
-
const env = {
|
|
1896
|
-
...process.env,
|
|
1897
|
-
ROOTPATH: dataDir,
|
|
1898
|
-
HARPER_SET_CONFIG: harperSetConfig,
|
|
1899
|
-
DEFAULTS_MODE: "dev",
|
|
1900
|
-
HDB_ADMIN_USERNAME: adminUser,
|
|
1901
|
-
HDB_ADMIN_PASSWORD: adminPass,
|
|
1902
|
-
THREADS_COUNT: "1",
|
|
1903
|
-
NODE_HOSTNAME: "localhost",
|
|
1904
|
-
HTTP_PORT: String(httpPort),
|
|
1905
|
-
OPERATIONSAPI_NETWORK_PORT: String(opsPort),
|
|
1906
|
-
LOCAL_STUDIO: "false",
|
|
1907
|
-
};
|
|
1908
|
-
if (alreadyInstalled) {
|
|
1909
|
-
console.log("Existing Harper installation found — skipping install.");
|
|
1910
|
-
}
|
|
1911
|
-
else {
|
|
1912
|
-
const installEnv = { ...env, HOME: join(dataDir, "..") };
|
|
1913
|
-
console.log("Installing Harper...");
|
|
1914
|
-
console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
|
|
1915
|
-
await new Promise((resolve, reject) => {
|
|
1916
|
-
let output = "";
|
|
1917
|
-
let dotTimer = null;
|
|
1918
|
-
const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
|
|
1919
|
-
dotTimer = setInterval(() => process.stdout.write("."), 3000);
|
|
1920
|
-
install.stdout?.on("data", (d) => { output += d.toString(); });
|
|
1921
|
-
install.stderr?.on("data", (d) => { output += d.toString(); });
|
|
1922
|
-
install.on("exit", (code) => {
|
|
1923
|
-
if (dotTimer) {
|
|
1924
|
-
clearInterval(dotTimer);
|
|
1925
|
-
process.stdout.write("\n");
|
|
1926
|
-
}
|
|
1927
|
-
code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
|
|
1928
|
-
});
|
|
1929
|
-
install.on("error", (err) => {
|
|
1930
|
-
if (dotTimer) {
|
|
1931
|
-
clearInterval(dotTimer);
|
|
1932
|
-
process.stdout.write("\n");
|
|
1933
|
-
}
|
|
1934
|
-
reject(err);
|
|
1935
|
-
});
|
|
1936
|
-
setTimeout(() => {
|
|
1937
|
-
install.kill();
|
|
1938
|
-
if (dotTimer) {
|
|
1939
|
-
clearInterval(dotTimer);
|
|
1940
|
-
process.stdout.write("\n");
|
|
1941
|
-
}
|
|
1942
|
-
reject(new Error(`Harper install timed out: ${output}`));
|
|
1943
|
-
}, 60_000);
|
|
1944
|
-
});
|
|
1945
|
-
}
|
|
1946
|
-
// Start Harper
|
|
1947
|
-
console.log(`Starting Harper on port ${httpPort}...`);
|
|
1948
|
-
const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
|
|
1949
|
-
proc.unref();
|
|
1950
|
-
}
|
|
1951
|
-
// Wait for health
|
|
1952
|
-
console.log("Waiting for Harper health check...");
|
|
1953
|
-
await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
|
|
1954
|
-
console.log("Harper is healthy ✓");
|
|
1955
|
-
// Write config so other commands can find this instance
|
|
1956
|
-
writeConfig(httpPort);
|
|
1957
|
-
}
|
|
1958
|
-
else {
|
|
1959
|
-
// Flair already initialized — resolve admin pass from env, falling back to
|
|
1960
|
-
// the persisted ~/.flair/admin-pass written at first install. Agent
|
|
1961
|
-
// seeding now goes through the ops API (#499), which always requires Basic
|
|
1962
|
-
// admin auth (it does NOT honor authorizeLocal), so a re-run of
|
|
1963
|
-
// `flair install --agent <new-id>` against an already-initialized local
|
|
1964
|
-
// Flair needs a real pass even when no env var is set.
|
|
1965
|
-
adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1966
|
-
if (!adminPass) {
|
|
1967
|
-
try {
|
|
1968
|
-
const persisted = join(homedir(), ".flair", "admin-pass");
|
|
1969
|
-
if (existsSync(persisted))
|
|
1970
|
-
adminPass = readFileSync(persisted, "utf-8").trim();
|
|
1971
|
-
}
|
|
1972
|
-
catch { /* fall through with empty pass */ }
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1976
|
-
// ── Step 2: Detect MCP clients ──
|
|
1977
|
-
let clients = detectClients();
|
|
1978
|
-
if (selectedClients.length > 0) {
|
|
1979
|
-
// Explicit --client flag — override detection
|
|
1980
|
-
if (clientOpt === "all" || clientOpt === "none" || noMcp) {
|
|
1981
|
-
// all/none handled below
|
|
1982
|
-
}
|
|
1983
|
-
else {
|
|
1984
|
-
// Filter to only the selected client (preserving wire function from detectClients)
|
|
1985
|
-
clients = clients.filter(c => selectedClients.includes(c.id));
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
if (!clientOpt && !noMcp) {
|
|
1989
|
-
// No --client flag — print detected clients
|
|
1990
|
-
const detected = clients.filter(c => c.detected);
|
|
1991
|
-
if (detected.length === 0) {
|
|
1992
|
-
console.log("No MCP clients detected. Run with --client <name> to wire a specific client.");
|
|
1993
|
-
}
|
|
1994
|
-
else {
|
|
1995
|
-
console.log(`Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
// ── Step 3: Resolve agent identity ──
|
|
1999
|
-
const agentId = opts.agent ?? (() => {
|
|
2000
|
-
try {
|
|
2001
|
-
const hn = hostname();
|
|
2002
|
-
return hn.split(".")[0];
|
|
2003
|
-
}
|
|
2004
|
-
catch {
|
|
2005
|
-
return "flair-agent";
|
|
2006
|
-
}
|
|
2007
|
-
})();
|
|
2008
|
-
// Validate agentId to prevent path traversal
|
|
2009
|
-
const VALID_AGENT_ID = /^[a-zA-Z0-9_-]+$/;
|
|
2010
|
-
if (!VALID_AGENT_ID.test(agentId)) {
|
|
2011
|
-
throw new Error(`Invalid agent ID: ${agentId}. Agent ID must contain only letters, numbers, underscores, and hyphens.`);
|
|
2012
|
-
}
|
|
2013
|
-
let agentExists = false;
|
|
2014
|
-
// Check if agent already exists locally
|
|
2015
|
-
const privPath = privKeyPath(agentId, keysDir);
|
|
2016
|
-
if (existsSync(privPath)) {
|
|
2017
|
-
agentExists = true;
|
|
2018
|
-
console.log(`Agent '${agentId}' already exists ✓`);
|
|
2019
|
-
}
|
|
2020
|
-
else {
|
|
2021
|
-
// Create agent
|
|
2022
|
-
mkdirSync(keysDir, { recursive: true });
|
|
2023
|
-
const pubPath = pubKeyPath(agentId, keysDir);
|
|
2024
|
-
console.log("Generating Ed25519 keypair...");
|
|
2025
|
-
const kp = nacl.sign.keyPair();
|
|
2026
|
-
const seed = kp.secretKey.slice(0, 32);
|
|
2027
|
-
writeFileSync(privPath, Buffer.from(seed));
|
|
2028
|
-
chmodSync(privPath, 0o600);
|
|
2029
|
-
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
2030
|
-
const pubKeyB64url = b64url(kp.publicKey);
|
|
2031
|
-
console.log(`Keypair written: ${privPath} ✓`);
|
|
2032
|
-
// Seed agent via the Harper operations API. The Agent table is a REST
|
|
2033
|
-
// resource without a POST handler, so the insert body must go to the ops
|
|
2034
|
-
// API (POST <opsUrl>/ with {operation:"insert",...}), NOT the REST root
|
|
2035
|
-
// (which 405s the body as a collection POST to /Agent). This is the same
|
|
2036
|
-
// path `flair agent add` uses. (#499)
|
|
2037
|
-
const seedOpsTarget = opts.opsTarget ?? opsPort;
|
|
2038
|
-
console.log(opts.opsTarget
|
|
2039
|
-
? `Seeding agent '${agentId}' via operations API (--ops-target)...`
|
|
2040
|
-
: `Seeding agent '${agentId}' via operations API...`);
|
|
2041
|
-
await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, adminUser, adminPass);
|
|
2042
|
-
console.log(`Agent '${agentId}' registered ✓`);
|
|
2043
|
-
}
|
|
2044
|
-
// ── Step 4: Wire MCP clients ──
|
|
2045
|
-
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
2046
|
-
const wiringResults = [];
|
|
2047
|
-
if (!noMcp && clientOpt !== "none") {
|
|
2048
|
-
const toWire = clientOpt === "all"
|
|
2049
|
-
? clients.filter(c => c.detected).map(c => c.id)
|
|
2050
|
-
: selectedClients.length > 0
|
|
2051
|
-
? selectedClients
|
|
2052
|
-
: clients.filter(c => c.detected).map(c => c.id);
|
|
2053
|
-
for (const clientId of toWire) {
|
|
2054
|
-
let result;
|
|
2055
|
-
switch (clientId) {
|
|
2056
|
-
case "claude-code":
|
|
2057
|
-
result = wireClaudeCode(mcpEnv);
|
|
2058
|
-
break;
|
|
2059
|
-
case "codex":
|
|
2060
|
-
result = wireCodex(mcpEnv);
|
|
2061
|
-
break;
|
|
2062
|
-
case "gemini":
|
|
2063
|
-
result = wireGemini(mcpEnv);
|
|
2064
|
-
break;
|
|
2065
|
-
case "cursor":
|
|
2066
|
-
result = wireCursor(mcpEnv);
|
|
2067
|
-
break;
|
|
2068
|
-
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
2069
|
-
}
|
|
2070
|
-
wiringResults.push({ client: clientId, message: result.message });
|
|
2071
|
-
console.log(` ${result.ok ? "✓" : "✗"} ${result.message}`);
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
// ── Step 5: Smoke test the MCP server ──
|
|
2075
|
-
if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
|
|
2076
|
-
console.log("Smoke-testing MCP server...");
|
|
2077
|
-
try {
|
|
2078
|
-
// Launch flair-mcp and send initialize request over stdio
|
|
2079
|
-
const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
|
|
2080
|
-
env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
|
|
2081
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
2082
|
-
timeout: 15_000,
|
|
2083
|
-
});
|
|
2084
|
-
// Send initialize request
|
|
2085
|
-
const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-install", version: "1.0.0" } } });
|
|
2086
|
-
mcpProc.stdin.write(initMsg + "\n");
|
|
2087
|
-
mcpProc.stdin.end();
|
|
2088
|
-
let stdout = "";
|
|
2089
|
-
mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
2090
|
-
await new Promise((resolve, reject) => {
|
|
2091
|
-
mcpProc.on("exit", (code) => {
|
|
2092
|
-
if (code === 0 && stdout.length > 0) {
|
|
2093
|
-
resolve();
|
|
2094
|
-
}
|
|
2095
|
-
else {
|
|
2096
|
-
reject(new Error(`MCP server exited with code ${code}`));
|
|
2097
|
-
}
|
|
2098
|
-
});
|
|
2099
|
-
mcpProc.on("error", reject);
|
|
2100
|
-
setTimeout(() => { mcpProc.kill(); reject(new Error("MCP smoke test timed out")); }, 15_000);
|
|
2101
|
-
});
|
|
2102
|
-
// Check for a valid JSON-RPC response
|
|
2103
|
-
try {
|
|
2104
|
-
const lines = stdout.split("\n").filter(l => l.trim());
|
|
2105
|
-
for (const line of lines) {
|
|
2106
|
-
const parsed = JSON.parse(line);
|
|
2107
|
-
if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
|
|
2108
|
-
console.log(" ✓ MCP server responded");
|
|
2109
|
-
break;
|
|
2110
|
-
}
|
|
2111
|
-
}
|
|
2112
|
-
}
|
|
2113
|
-
catch {
|
|
2114
|
-
console.log(" ⚠ MCP server responded but response could not be parsed");
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
catch (err) {
|
|
2118
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2119
|
-
console.log(` ⚠ MCP smoke test failed: ${message}`);
|
|
2120
|
-
console.log(" Use --skip-smoke to bypass.");
|
|
2121
|
-
}
|
|
2122
|
-
}
|
|
2123
|
-
// ── Step 6: Summary ──
|
|
2124
|
-
console.log("");
|
|
2125
|
-
console.log("✓ Flair installed.");
|
|
2126
|
-
console.log(` Agent: ${agentId}`);
|
|
2127
|
-
if (existsSync(privKeyPath(agentId, keysDir))) {
|
|
2128
|
-
console.log(` Private key: ${privKeyPath(agentId, keysDir)}`);
|
|
2129
|
-
}
|
|
2130
|
-
console.log(` Local: ${httpUrl}`);
|
|
2131
|
-
console.log(` MCP: ${wiringResults.length > 0 ? wiringResults.map(r => r.client).join(", ") + " ✓ wired" : "none wired"}`);
|
|
2132
|
-
console.log("");
|
|
2133
|
-
console.log(` Try it: in Claude Code, ask the agent "what do you remember about me?"`);
|
|
2081
|
+
// All init work is genuinely done at this point: Harper is installed +
|
|
2082
|
+
// running (detached, unref'd — survives this process exiting), the agent is
|
|
2083
|
+
// registered, semantic search is verified, MCP clients are wired, and the
|
|
2084
|
+
// smoke test ran. The MCP smoke subprocess can leave a lingering npx handle
|
|
2085
|
+
// that pins Node's event loop for ~60s after success ("rc=0 but doesn't
|
|
2086
|
+
// return"). We've cleared/unref'd the known timers above; exit explicitly so
|
|
2087
|
+
// the prompt returns in a couple seconds regardless of any stray handle. The
|
|
2088
|
+
// running Harper instance is unaffected.
|
|
2089
|
+
await new Promise((r) => process.stdout.write("", () => r()));
|
|
2090
|
+
process.exit(0);
|
|
2134
2091
|
});
|
|
2135
2092
|
// ─── flair agent ─────────────────────────────────────────────────────────────
|
|
2136
2093
|
const agent = program.command("agent").description("Manage Flair agents");
|
|
@@ -2142,6 +2099,8 @@ agent
|
|
|
2142
2099
|
.option("--admin-pass <pass>", "Admin password for registration")
|
|
2143
2100
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
2144
2101
|
.option("--ops-port <port>", "Harper operations API port")
|
|
2102
|
+
.option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
|
|
2103
|
+
.option("--ops-target <url>", "Explicit ops API URL to seed the Agent on (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
2145
2104
|
.action(async (id, opts) => {
|
|
2146
2105
|
const httpPort = resolveHttpPort(opts);
|
|
2147
2106
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -2149,6 +2108,11 @@ agent
|
|
|
2149
2108
|
const adminPass = opts.adminPass;
|
|
2150
2109
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
2151
2110
|
const name = opts.name ?? id;
|
|
2111
|
+
// Where to seed the Agent record. Default is localhost (opsPort). When
|
|
2112
|
+
// --ops-target or --target is given, seed on the remote instead of localhost
|
|
2113
|
+
// (#514 — agent add could only ever hit localhost ops). Precedence matches
|
|
2114
|
+
// `flair import`: explicit --ops-target > derive from --target > localhost.
|
|
2115
|
+
const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
|
|
2152
2116
|
if (!adminPass) {
|
|
2153
2117
|
console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table)");
|
|
2154
2118
|
process.exit(1);
|
|
@@ -2172,8 +2136,10 @@ agent
|
|
|
2172
2136
|
pubKeyB64url = b64url(kp.publicKey);
|
|
2173
2137
|
console.log(`Keypair written: ${privPath}`);
|
|
2174
2138
|
}
|
|
2175
|
-
await seedAgentViaOpsApi(
|
|
2176
|
-
console.log(
|
|
2139
|
+
await seedAgentViaOpsApi(seedOpsTarget, id, pubKeyB64url, adminUser, adminPass);
|
|
2140
|
+
console.log(typeof seedOpsTarget === "string"
|
|
2141
|
+
? `✅ Agent '${id}' (${name}) registered (ops: ${seedOpsTarget})`
|
|
2142
|
+
: `✅ Agent '${id}' (${name}) registered`);
|
|
2177
2143
|
console.log(` Private key: ${privPath}`);
|
|
2178
2144
|
console.log(` Public key: ${pubKeyB64url}`);
|
|
2179
2145
|
});
|
|
@@ -2181,6 +2147,8 @@ agent
|
|
|
2181
2147
|
.command("list")
|
|
2182
2148
|
.description("List all agents")
|
|
2183
2149
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
2150
|
+
.option("--agent <id>", "Agent ID to authenticate as via Ed25519 (or FLAIR_AGENT_ID env) when no admin pass")
|
|
2151
|
+
.option("--keys-dir <dir>", "Directory holding the agent's Ed25519 key")
|
|
2184
2152
|
.option("--port <port>", "Harper HTTP port")
|
|
2185
2153
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
2186
2154
|
.action(async (opts) => {
|
|
@@ -2216,13 +2184,39 @@ agent
|
|
|
2216
2184
|
agents = await res.json();
|
|
2217
2185
|
}
|
|
2218
2186
|
else {
|
|
2187
|
+
// No admin pass → authenticate as the AGENT via Ed25519. The Agent table's
|
|
2188
|
+
// allowRead() is allowVerified — a bare unauthenticated GET /Agent returns
|
|
2189
|
+
// 403 AccessViolation (the dogfood symptom: the natural "did my agent
|
|
2190
|
+
// register?" check errored on a healthy install). A verified agent reads
|
|
2191
|
+
// the principal table for discovery, so sign the request with its key.
|
|
2219
2192
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
2220
|
-
const
|
|
2221
|
-
|
|
2222
|
-
|
|
2193
|
+
const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
|
|
2194
|
+
const keysDir = opts.keysDir ?? process.env.FLAIR_KEY_DIR ?? defaultKeysDir();
|
|
2195
|
+
let res;
|
|
2196
|
+
if (agentId) {
|
|
2197
|
+
const keyPath = resolveKeyPath(agentId) ?? join(keysDir, `${agentId}.key`);
|
|
2198
|
+
if (!existsSync(keyPath)) {
|
|
2199
|
+
console.error(`${render.icons.error} no key for agent '${agentId}' (looked in ${keysDir}). Pass --admin-pass, --keys-dir, or a registered --agent.`);
|
|
2200
|
+
process.exit(1);
|
|
2201
|
+
}
|
|
2202
|
+
res = await authFetch(baseUrl, agentId, keyPath, "GET", "/Agent");
|
|
2203
|
+
}
|
|
2204
|
+
else {
|
|
2205
|
+
// No agent identity available either. Try anonymously, but if it 403s
|
|
2206
|
+
// (the common case), tell the user exactly how to authenticate rather
|
|
2207
|
+
// than dumping a raw AccessViolation.
|
|
2208
|
+
res = await fetch(`${baseUrl}/Agent`, { headers: { "Content-Type": "application/json" } });
|
|
2209
|
+
}
|
|
2223
2210
|
if (!res.ok) {
|
|
2224
2211
|
const text = await res.text().catch(() => "");
|
|
2225
|
-
|
|
2212
|
+
if (res.status === 403 && !agentId) {
|
|
2213
|
+
console.error(`${render.icons.error} 403 — listing agents requires authentication.`);
|
|
2214
|
+
console.error(` Use ${render.wrap(render.c.cyan, "--agent <id>")} (or set FLAIR_AGENT_ID) to authenticate as a registered agent,`);
|
|
2215
|
+
console.error(` or ${render.wrap(render.c.cyan, "--admin-pass")} / FLAIR_ADMIN_PASS for the admin view.`);
|
|
2216
|
+
}
|
|
2217
|
+
else {
|
|
2218
|
+
console.error(`${render.icons.error} ${res.status} ${text}`);
|
|
2219
|
+
}
|
|
2226
2220
|
process.exit(1);
|
|
2227
2221
|
}
|
|
2228
2222
|
const data = await res.json();
|
|
@@ -5766,14 +5760,104 @@ statusCmd
|
|
|
5766
5760
|
console.log("\n✅ no warnings");
|
|
5767
5761
|
}
|
|
5768
5762
|
});
|
|
5763
|
+
// ─── flair upgrade --target <fabric> ────────────────────────────────────────
|
|
5764
|
+
//
|
|
5765
|
+
// One-command upgrade of a Flair instance DEPLOYED to a Harper Fabric cluster.
|
|
5766
|
+
// Mirrors `flair deploy`'s credential handling (FABRIC_USER/FABRIC_PASSWORD env
|
|
5767
|
+
// fallbacks, password-via-flag warning) and NEVER prints credentials. The
|
|
5768
|
+
// version-resolution + @harperfast/harper pin + reuse of deploy() lives in
|
|
5769
|
+
// src/fabric-upgrade.ts; this wrapper only does CLI plumbing + the confirm.
|
|
5770
|
+
async function runFabricUpgrade(opts) {
|
|
5771
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
5772
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
5773
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
5774
|
+
const fabricUser = opts.fabricUser ?? process.env.FABRIC_USER;
|
|
5775
|
+
const fabricPassword = opts.fabricPassword ?? process.env.FABRIC_PASSWORD;
|
|
5776
|
+
const check = opts.check ?? false;
|
|
5777
|
+
// Creds are not required for --check (read-only registry + best-effort GET),
|
|
5778
|
+
// but ARE required to actually deploy.
|
|
5779
|
+
if (!check && !(fabricUser && fabricPassword)) {
|
|
5780
|
+
console.error(red("flair upgrade --target: credentials required to deploy"));
|
|
5781
|
+
console.error(" pass --fabric-user + --fabric-password (or FABRIC_USER / FABRIC_PASSWORD env)");
|
|
5782
|
+
console.error(" or use --check to preview the plan without credentials");
|
|
5783
|
+
process.exit(1);
|
|
5784
|
+
}
|
|
5785
|
+
// Warn on password-via-flag (leaks to shell history). Env is preferred.
|
|
5786
|
+
if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
|
|
5787
|
+
console.error(dim("warning: --fabric-password leaks to shell history. Prefer FABRIC_PASSWORD env."));
|
|
5788
|
+
}
|
|
5789
|
+
const upgradeOpts = {
|
|
5790
|
+
target: opts.target,
|
|
5791
|
+
project: opts.project,
|
|
5792
|
+
version: opts.version,
|
|
5793
|
+
harperVersion: opts.harperVersion,
|
|
5794
|
+
fabricUser,
|
|
5795
|
+
fabricPassword,
|
|
5796
|
+
check,
|
|
5797
|
+
restart: opts.restart !== false,
|
|
5798
|
+
replicated: opts.replicated !== false,
|
|
5799
|
+
};
|
|
5800
|
+
console.log(`${green("→")} Upgrading Fabric Flair at ${upgradeOpts.target}`);
|
|
5801
|
+
if (check)
|
|
5802
|
+
console.log(dim(" (--check: plan only, no deploy)"));
|
|
5803
|
+
try {
|
|
5804
|
+
// For a real (non-check) run, confirm first unless --yes. Building the plan
|
|
5805
|
+
// up front would double the registry round-trips; the plan prints inside
|
|
5806
|
+
// fabricUpgrade. We confirm BEFORE invoking when interactive and not --yes.
|
|
5807
|
+
if (!check && !opts.yes && process.stdin.isTTY) {
|
|
5808
|
+
const { createInterface } = await import("node:readline");
|
|
5809
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
5810
|
+
const answer = await new Promise((res) => rl.question(`Deploy a fresh ${green("@tpsdev-ai/flair")} to ${upgradeOpts.target}? [y/N] `, (a) => { rl.close(); res(a); }));
|
|
5811
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
5812
|
+
console.log("Aborted.");
|
|
5813
|
+
return;
|
|
5814
|
+
}
|
|
5815
|
+
}
|
|
5816
|
+
const result = await fabricUpgrade(upgradeOpts);
|
|
5817
|
+
if (check) {
|
|
5818
|
+
console.log(`\n${green("✓")} check complete — run without --check to deploy.`);
|
|
5819
|
+
return;
|
|
5820
|
+
}
|
|
5821
|
+
if (result.plan.upToDate && !result.deployed) {
|
|
5822
|
+
console.log(`\n${green("✓")} already up to date.`);
|
|
5823
|
+
return;
|
|
5824
|
+
}
|
|
5825
|
+
console.log(`\n${green("✓")} Fabric upgrade complete.`);
|
|
5826
|
+
}
|
|
5827
|
+
catch (err) {
|
|
5828
|
+
console.error(red(`\n✗ fabric upgrade failed: ${err.message}`));
|
|
5829
|
+
const hint = err.message?.toLowerCase() ?? "";
|
|
5830
|
+
if (hint.includes("401") || hint.includes("unauthoriz")) {
|
|
5831
|
+
console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
|
|
5832
|
+
}
|
|
5833
|
+
process.exit(1);
|
|
5834
|
+
}
|
|
5835
|
+
}
|
|
5769
5836
|
// ─── flair upgrade ────────────────────────────────────────────────────────────
|
|
5770
5837
|
program
|
|
5771
5838
|
.command("upgrade")
|
|
5772
|
-
.description("Upgrade Flair
|
|
5773
|
-
.option("--check", "Only check for updates, don't install")
|
|
5839
|
+
.description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
|
|
5840
|
+
.option("--check", "Only check for updates / show the plan, don't install or deploy")
|
|
5774
5841
|
.option("--restart", "Restart Flair after upgrade")
|
|
5775
5842
|
.option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
|
|
5843
|
+
// ── Fabric upgrade (--target) ────────────────────────────────────────────
|
|
5844
|
+
// When --target is passed, upgrade the Flair component DEPLOYED to that
|
|
5845
|
+
// Harper Fabric URL instead of the local npm install. Reuses `flair deploy`
|
|
5846
|
+
// under the hood with the @harperfast/harper pin baked in (flair#513).
|
|
5847
|
+
.option("--target <url>", "Upgrade the Flair deployed to this Fabric URL (not the local install)")
|
|
5848
|
+
.option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER) — for --target")
|
|
5849
|
+
.option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD) — for --target")
|
|
5850
|
+
.option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
|
|
5851
|
+
.option("--harper-version <semver>", "Pin @harperfast/harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
|
|
5852
|
+
.option("--project <name>", "Fabric component name for --target", "flair")
|
|
5853
|
+
.option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
|
|
5854
|
+
.option("--yes", "Skip the confirmation prompt for --target")
|
|
5776
5855
|
.action(async (opts) => {
|
|
5856
|
+
// ── Fabric-upgrade branch ───────────────────────────────────────────────
|
|
5857
|
+
if (opts.target) {
|
|
5858
|
+
await runFabricUpgrade(opts);
|
|
5859
|
+
return;
|
|
5860
|
+
}
|
|
5777
5861
|
const { execSync, execFileSync } = await import("node:child_process");
|
|
5778
5862
|
const checkOnly = opts.check ?? false;
|
|
5779
5863
|
const showAll = opts.all ?? false;
|
|
@@ -6600,6 +6684,7 @@ program
|
|
|
6600
6684
|
.command("doctor")
|
|
6601
6685
|
.description("Diagnose common Flair problems and suggest fixes")
|
|
6602
6686
|
.option("--port <port>", "Harper HTTP port")
|
|
6687
|
+
.option("--agent <id>", "Agent ID to use for the semantic-search round-trip (or FLAIR_AGENT_ID env)")
|
|
6603
6688
|
.option("--fix", "Automatically fix issues where possible")
|
|
6604
6689
|
.option("--dry-run", "Show what --fix would do without making changes")
|
|
6605
6690
|
.action(async (opts) => {
|
|
@@ -6762,32 +6847,38 @@ program
|
|
|
6762
6847
|
else {
|
|
6763
6848
|
console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
|
|
6764
6849
|
}
|
|
6765
|
-
// 4. Embeddings check (only if Harper is responding)
|
|
6850
|
+
// 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
|
|
6851
|
+
//
|
|
6852
|
+
// The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
|
|
6853
|
+
// not loaded: SemanticSearch falls back to keyword-only scan, and an
|
|
6854
|
+
// unauthenticated probe 401s → "cannot verify" → no issue counted. A clean-VM
|
|
6855
|
+
// dogfood found semantic search DEAD out of the box (sudo/root-owned install
|
|
6856
|
+
// can't write the models symlink → EACCES) while `flair doctor` reported
|
|
6857
|
+
// "no issues found". This now stores a memory with a distinctive phrase and
|
|
6858
|
+
// searches for a PARAPHRASE (no shared keywords). If the top result isn't
|
|
6859
|
+
// recovered by MEANING, recall-by-meaning is broken and doctor FAILS LOUDLY.
|
|
6766
6860
|
if (harperResponding) {
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
}
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
console.log(` ${render.icons.warn} Embeddings: cannot verify ${render.wrap(render.c.dim, "(auth required for SemanticSearch)")}`);
|
|
6788
|
-
}
|
|
6861
|
+
const semanticStatus = await verifySemanticSearch(baseUrl, opts.agent, defaultKeysDir());
|
|
6862
|
+
switch (semanticStatus.state) {
|
|
6863
|
+
case "ok":
|
|
6864
|
+
console.log(` ${render.icons.ok} Embeddings: semantic search operational ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${semanticStatus.score.toFixed(2)})`)}`);
|
|
6865
|
+
break;
|
|
6866
|
+
case "degraded":
|
|
6867
|
+
// LOUD failure — never report all-clear when recall-by-meaning is dead.
|
|
6868
|
+
console.log(` ${render.icons.error} Semantic search DEGRADED ${render.wrap(render.c.dim, `— ${semanticStatus.detail}`)}`);
|
|
6869
|
+
console.log(` ${render.wrap(render.c.red, "Embeddings are not loaded; recall-by-meaning will NOT work.")}`);
|
|
6870
|
+
console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
|
|
6871
|
+
console.log(` ${render.wrap(render.c.dim, "See:")} docs/troubleshooting.md ${render.wrap(render.c.dim, "→ \"Semantic search DEGRADED\"")}`);
|
|
6872
|
+
issues++;
|
|
6873
|
+
break;
|
|
6874
|
+
case "skipped":
|
|
6875
|
+
// Could not run the round-trip (no agent / no key). Don't claim
|
|
6876
|
+
// all-clear — surface that the check was skipped, but don't count it
|
|
6877
|
+
// as a hard issue since the user may simply not have an agent yet.
|
|
6878
|
+
console.log(` ${render.icons.warn} Embeddings: not verified ${render.wrap(render.c.dim, `(${semanticStatus.detail})`)}`);
|
|
6879
|
+
console.log(` ${render.wrap(render.c.dim, "Pass --agent <id> (or set FLAIR_AGENT_ID) so doctor can run a real semantic round-trip.")}`);
|
|
6880
|
+
break;
|
|
6789
6881
|
}
|
|
6790
|
-
catch { /* fetch error, already flagged */ }
|
|
6791
6882
|
}
|
|
6792
6883
|
// 5. Stale PID file (skip if already reported in port check)
|
|
6793
6884
|
const dataDir = defaultDataDir();
|
|
@@ -8657,11 +8748,20 @@ program
|
|
|
8657
8748
|
.option("--port <port>", "Harper HTTP port")
|
|
8658
8749
|
.option("--ops-port <port>", "Harper operations API port")
|
|
8659
8750
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
8751
|
+
.option("--ops-target <url>", "Explicit ops API URL for the Agent seed (env: FLAIR_OPS_TARGET; bypasses port derivation). Use when --url is remote and the ops port isn't HTTP-1.")
|
|
8660
8752
|
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
8661
8753
|
.option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
|
|
8662
8754
|
.action(async (importPath, opts) => {
|
|
8663
8755
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
8664
8756
|
const opsPort = resolveOpsPort(opts);
|
|
8757
|
+
// Resolve where the Agent record is seeded. The Agent goes through the ops
|
|
8758
|
+
// API (the REST surface has no Agent POST handler), so a remote --url import
|
|
8759
|
+
// must NOT silently seed on localhost (#514 — split import). Precedence:
|
|
8760
|
+
// 1. --ops-target / FLAIR_OPS_TARGET → use directly
|
|
8761
|
+
// 2. --url given → derive ops URL from it (port-1 convention)
|
|
8762
|
+
// 3. neither → localhost opsPort (preserves local default)
|
|
8763
|
+
// The remote REST base (--url) is mapped to `target` for derivation.
|
|
8764
|
+
const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.url, opsTarget: opts.opsTarget }) ?? opsPort;
|
|
8665
8765
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
8666
8766
|
if (!adminPass) {
|
|
8667
8767
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
@@ -8709,9 +8809,11 @@ program
|
|
|
8709
8809
|
? nacl.sign.keyPair.fromSeed(new Uint8Array(decodedSeed)).publicKey
|
|
8710
8810
|
: nacl.sign.keyPair.fromSeed(new Uint8Array(decodedSeed.subarray(0, 32))).publicKey;
|
|
8711
8811
|
const pubKeyB64url = b64url(pubKey);
|
|
8712
|
-
// Register agent via ops API
|
|
8713
|
-
await seedAgentViaOpsApi(
|
|
8714
|
-
console.log(
|
|
8812
|
+
// Register agent via ops API (remote when --url/--ops-target points off-box)
|
|
8813
|
+
await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, DEFAULT_ADMIN_USER, adminPass);
|
|
8814
|
+
console.log(typeof seedOpsTarget === "string"
|
|
8815
|
+
? ` Agent registered (ops: ${seedOpsTarget})`
|
|
8816
|
+
: ` Agent registered`);
|
|
8715
8817
|
// Restore memories
|
|
8716
8818
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
8717
8819
|
let memCount = 0;
|
|
@@ -9105,9 +9207,159 @@ presence
|
|
|
9105
9207
|
console.log(` Status: ${data.presenceStatus}`);
|
|
9106
9208
|
}
|
|
9107
9209
|
});
|
|
9108
|
-
//
|
|
9109
|
-
|
|
9210
|
+
// ─── flair workspace ─────────────────────────────────────────────────────────
|
|
9211
|
+
//
|
|
9212
|
+
// Coordination write surface (ops-wmgx / Kris #510). `workspace set` writes the
|
|
9213
|
+
// agent's OWN WorkspaceState via a signed POST /WorkspaceState. Identity comes
|
|
9214
|
+
// from the Ed25519 signature — the body carries NO agentId, so an agent can only
|
|
9215
|
+
// write as itself (the WorkspaceState.post() handler overwrites agentId from the
|
|
9216
|
+
// signature regardless of body content). Mirrors `presence set`'s signed-POST shape.
|
|
9217
|
+
const MAX_WORKSPACE_FIELD_LENGTH = 2000;
|
|
9218
|
+
const workspace = program.command("workspace").description("Manage agent workspace state (The Office Space)");
|
|
9219
|
+
workspace
|
|
9220
|
+
.command("set")
|
|
9221
|
+
.description("Set your agent's current workspace state (POST /WorkspaceState)")
|
|
9222
|
+
.requiredOption("--ref <ref>", "Workspace ref (branch, worktree, or task ref)")
|
|
9223
|
+
.option("--label <text>", "Human-readable label for this workspace")
|
|
9224
|
+
.option("--provider <name>", "Provider/runtime (e.g. claude-code, openclaw)", "cli")
|
|
9225
|
+
.option("--task <id>", "Task/issue id this workspace is attached to")
|
|
9226
|
+
.option("--phase <phase>", "Current phase (e.g. design, implement, review)")
|
|
9227
|
+
.option("--summary <text>", "Short summary of current workspace state")
|
|
9228
|
+
.option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
|
|
9229
|
+
.option("--port <port>", "Harper HTTP port")
|
|
9230
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
9231
|
+
.action(async (opts) => {
|
|
9232
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
9233
|
+
if (!agentId) {
|
|
9234
|
+
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
9235
|
+
process.exit(1);
|
|
9236
|
+
}
|
|
9237
|
+
// Validate field lengths (free text → cap to bound the write).
|
|
9238
|
+
for (const [name, val] of [["ref", opts.ref], ["label", opts.label], ["summary", opts.summary]]) {
|
|
9239
|
+
if (val && String(val).length > MAX_WORKSPACE_FIELD_LENGTH) {
|
|
9240
|
+
console.error(`Error: --${name} exceeds ${MAX_WORKSPACE_FIELD_LENGTH} character limit (got ${String(val).length}).`);
|
|
9241
|
+
process.exit(1);
|
|
9242
|
+
}
|
|
9243
|
+
}
|
|
9244
|
+
const keyPath = resolveKeyPath(agentId);
|
|
9245
|
+
if (!keyPath) {
|
|
9246
|
+
console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
|
|
9247
|
+
process.exit(1);
|
|
9248
|
+
}
|
|
9249
|
+
const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
|
|
9250
|
+
const auth = buildEd25519Auth(agentId, "POST", "/WorkspaceState", keyPath);
|
|
9251
|
+
// NOTE: agentId is intentionally NOT included in the body — the handler
|
|
9252
|
+
// attributes the record from the Ed25519 signature (no forging).
|
|
9253
|
+
const now = new Date().toISOString();
|
|
9254
|
+
const body = {
|
|
9255
|
+
id: `${agentId}:${opts.ref}`,
|
|
9256
|
+
ref: opts.ref,
|
|
9257
|
+
provider: opts.provider ?? "cli",
|
|
9258
|
+
timestamp: now,
|
|
9259
|
+
};
|
|
9260
|
+
if (opts.label)
|
|
9261
|
+
body.label = opts.label;
|
|
9262
|
+
if (opts.task)
|
|
9263
|
+
body.taskId = opts.task;
|
|
9264
|
+
if (opts.phase)
|
|
9265
|
+
body.phase = opts.phase;
|
|
9266
|
+
if (opts.summary)
|
|
9267
|
+
body.summary = opts.summary;
|
|
9268
|
+
const res = await fetch(`${baseUrl}/WorkspaceState`, {
|
|
9269
|
+
method: "POST",
|
|
9270
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
9271
|
+
body: JSON.stringify(body),
|
|
9272
|
+
});
|
|
9273
|
+
if (!res.ok) {
|
|
9274
|
+
const text = await res.text().catch(() => "");
|
|
9275
|
+
console.error(`Error: POST /WorkspaceState failed (${res.status}): ${text}`);
|
|
9276
|
+
process.exit(1);
|
|
9277
|
+
}
|
|
9278
|
+
console.log(`✓ Workspace state updated for '${agentId}': ref=${opts.ref}${opts.phase ? `, phase=${opts.phase}` : ""}`);
|
|
9279
|
+
});
|
|
9280
|
+
// ─── flair orgevent ──────────────────────────────────────────────────────────
|
|
9281
|
+
//
|
|
9282
|
+
// Coordination write surface (ops-wmgx / Kris #510). `orgevent` publishes an
|
|
9283
|
+
// OrgEvent ATTRIBUTED to the authenticated agent via a signed POST /OrgEvent.
|
|
9284
|
+
// The event's authorId comes from the Ed25519 signature — the body carries NO
|
|
9285
|
+
// authorId, so an agent CANNOT forge another agent's events (the OrgEvent.post()
|
|
9286
|
+
// handler overwrites authorId from the signature). Mirrors `presence set`'s shape.
|
|
9287
|
+
const MAX_ORGEVENT_SUMMARY_LENGTH = 500;
|
|
9288
|
+
const MAX_ORGEVENT_DETAIL_LENGTH = 8000;
|
|
9289
|
+
program
|
|
9290
|
+
.command("orgevent")
|
|
9291
|
+
.description("Publish an org-wide coordination event attributed to your agent (POST /OrgEvent)")
|
|
9292
|
+
.requiredOption("--kind <kind>", "Event kind (e.g. coord.claim, coord.release, status)")
|
|
9293
|
+
.requiredOption("--summary <text>", "Short summary of the event")
|
|
9294
|
+
.option("--detail <text>", "Longer detail payload")
|
|
9295
|
+
.option("--scope <scope>", "Scope of the event (e.g. an agent id, repo, or 'org')")
|
|
9296
|
+
.option("--target <agentId>", "Recipient agent id (repeatable)", (val, acc) => { acc.push(val); return acc; }, [])
|
|
9297
|
+
.option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
|
|
9298
|
+
.option("--port <port>", "Harper HTTP port")
|
|
9299
|
+
.option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
9300
|
+
.action(async (opts) => {
|
|
9301
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
9302
|
+
if (!agentId) {
|
|
9303
|
+
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
9304
|
+
process.exit(1);
|
|
9305
|
+
}
|
|
9306
|
+
if (opts.summary && String(opts.summary).length > MAX_ORGEVENT_SUMMARY_LENGTH) {
|
|
9307
|
+
console.error(`Error: --summary exceeds ${MAX_ORGEVENT_SUMMARY_LENGTH} character limit (got ${String(opts.summary).length}).`);
|
|
9308
|
+
process.exit(1);
|
|
9309
|
+
}
|
|
9310
|
+
if (opts.detail && String(opts.detail).length > MAX_ORGEVENT_DETAIL_LENGTH) {
|
|
9311
|
+
console.error(`Error: --detail exceeds ${MAX_ORGEVENT_DETAIL_LENGTH} character limit (got ${String(opts.detail).length}).`);
|
|
9312
|
+
process.exit(1);
|
|
9313
|
+
}
|
|
9314
|
+
const keyPath = resolveKeyPath(agentId);
|
|
9315
|
+
if (!keyPath) {
|
|
9316
|
+
console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
|
|
9317
|
+
process.exit(1);
|
|
9318
|
+
}
|
|
9319
|
+
// orgevent reuses --target for recipients, so the remote-URL override is
|
|
9320
|
+
// --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
|
|
9321
|
+
const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
|
|
9322
|
+
const auth = buildEd25519Auth(agentId, "POST", "/OrgEvent", keyPath);
|
|
9323
|
+
// NOTE: authorId is intentionally NOT included in the body — the handler
|
|
9324
|
+
// attributes the event from the Ed25519 signature (no forging).
|
|
9325
|
+
const body = {
|
|
9326
|
+
kind: opts.kind,
|
|
9327
|
+
summary: opts.summary,
|
|
9328
|
+
};
|
|
9329
|
+
if (opts.detail)
|
|
9330
|
+
body.detail = opts.detail;
|
|
9331
|
+
if (opts.scope)
|
|
9332
|
+
body.scope = opts.scope;
|
|
9333
|
+
if (Array.isArray(opts.target) && opts.target.length > 0)
|
|
9334
|
+
body.targetIds = opts.target;
|
|
9335
|
+
const res = await fetch(`${baseUrl}/OrgEvent`, {
|
|
9336
|
+
method: "POST",
|
|
9337
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
9338
|
+
body: JSON.stringify(body),
|
|
9339
|
+
});
|
|
9340
|
+
if (!res.ok) {
|
|
9341
|
+
const text = await res.text().catch(() => "");
|
|
9342
|
+
console.error(`Error: POST /OrgEvent failed (${res.status}): ${text}`);
|
|
9343
|
+
process.exit(1);
|
|
9344
|
+
}
|
|
9345
|
+
const data = await res.json().catch(() => null);
|
|
9346
|
+
const targets = Array.isArray(opts.target) && opts.target.length > 0 ? ` → ${opts.target.join(", ")}` : "";
|
|
9347
|
+
console.log(`✓ OrgEvent published as '${agentId}': kind=${opts.kind}${targets}`);
|
|
9348
|
+
if (data?.id)
|
|
9349
|
+
console.log(` id: ${data.id}`);
|
|
9350
|
+
});
|
|
9351
|
+
// Parse argv and run the CLI. Exported so the CommonJS preflight shim
|
|
9352
|
+
// (cli-shim.cts → dist/cli-shim.cjs, the real bin entry) can invoke it after
|
|
9353
|
+
// its Node-version check passes. The shim imports this module, so import.meta.main
|
|
9354
|
+
// is false there — without this explicit entry point the CLI would load but never run.
|
|
9355
|
+
async function runCli() {
|
|
9110
9356
|
await program.parseAsync();
|
|
9111
9357
|
}
|
|
9358
|
+
// Run CLI directly when this file is the entry point — covers `node dist/cli.js`,
|
|
9359
|
+
// `bun src/cli.ts`, and the test harness (which spawns src/cli.ts under bun).
|
|
9360
|
+
// The packaged bin goes through cli-shim.cjs → runCli() instead.
|
|
9361
|
+
if (import.meta.main) {
|
|
9362
|
+
await runCli();
|
|
9363
|
+
}
|
|
9112
9364
|
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
9113
|
-
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
|
|
9365
|
+
export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
|