@tpsdev-ai/flair 0.15.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.js +379 -406
- package/dist/install/clients.js +132 -81
- package/dist/resources/embeddings-provider.js +41 -6
- package/package.json +8 -8
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ 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";
|
|
@@ -12,7 +12,7 @@ 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
14
|
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
15
|
-
import { detectClients,
|
|
15
|
+
import { detectClients, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
16
16
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
17
17
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
18
18
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -447,6 +447,117 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
|
|
|
447
447
|
}
|
|
448
448
|
throw new Error(`Harper at port ${httpPort} did not respond within ${timeoutMs}ms (${attempt} attempts)`);
|
|
449
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
|
+
}
|
|
450
561
|
// Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
|
|
451
562
|
// Used during restart to confirm the old Harper process actually exited before
|
|
452
563
|
// we start polling /Health — otherwise the still-shutting-down old process can
|
|
@@ -1231,8 +1342,9 @@ program.name("flair").version(__pkgVersion, "-v, --version");
|
|
|
1231
1342
|
// ─── flair init ──────────────────────────────────────────────────────────────
|
|
1232
1343
|
program
|
|
1233
1344
|
.command("init")
|
|
1234
|
-
.description("
|
|
1345
|
+
.description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
|
|
1235
1346
|
.option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
|
|
1347
|
+
.option("--agent <id>", "Alias for --agent-id")
|
|
1236
1348
|
.option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
|
|
1237
1349
|
.option("--ops-port <port>", "Harper operations API port")
|
|
1238
1350
|
.option("--admin-pass <pass>", "Admin password (generated if omitted)")
|
|
@@ -1241,6 +1353,9 @@ program
|
|
|
1241
1353
|
.option("--data-dir <dir>", "Harper data directory")
|
|
1242
1354
|
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
1243
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")
|
|
1244
1359
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
1245
1360
|
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
1246
1361
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
@@ -1249,7 +1364,7 @@ program
|
|
|
1249
1364
|
.option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
|
|
1250
1365
|
.option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
|
|
1251
1366
|
.action(async (opts) => {
|
|
1252
|
-
const agentId = opts.agentId;
|
|
1367
|
+
const agentId = opts.agentId ?? opts.agent;
|
|
1253
1368
|
const target = resolveTarget(opts);
|
|
1254
1369
|
const opsTarget = resolveOpsTarget(opts);
|
|
1255
1370
|
// ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
|
|
@@ -1426,11 +1541,26 @@ program
|
|
|
1426
1541
|
}
|
|
1427
1542
|
return;
|
|
1428
1543
|
}
|
|
1429
|
-
// ── Local init (
|
|
1544
|
+
// ── Local init (full one-command setup) ──
|
|
1430
1545
|
const httpPort = resolveHttpPort(opts);
|
|
1431
1546
|
const opsPort = resolveOpsPort(opts);
|
|
1432
1547
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
1433
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
|
+
}
|
|
1434
1564
|
// Admin password: determine from opts, env, or generate
|
|
1435
1565
|
// Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
|
|
1436
1566
|
let adminPass;
|
|
@@ -1688,6 +1818,26 @@ program
|
|
|
1688
1818
|
if (!verifyRes.ok)
|
|
1689
1819
|
throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
|
|
1690
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
|
+
}
|
|
1691
1841
|
// Output — admin password printed once, never written to disk
|
|
1692
1842
|
console.log("\n✅ Flair initialized successfully");
|
|
1693
1843
|
console.log(` Agent ID: ${agentId}`);
|
|
@@ -1747,38 +1897,164 @@ program
|
|
|
1747
1897
|
}
|
|
1748
1898
|
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
1749
1899
|
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
1750
|
-
//
|
|
1751
|
-
|
|
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.
|
|
1752
1907
|
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
1753
|
-
const
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
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.");
|
|
1765
1919
|
}
|
|
1766
1920
|
else {
|
|
1767
|
-
|
|
1768
|
-
claudeJson.mcpServers.flair = flairMcpConfig;
|
|
1769
|
-
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
1770
|
-
console.log(`\n MCP config written to ~/.claude.json ✓`);
|
|
1771
|
-
console.log(` Restart Claude Code to pick up the new config.`);
|
|
1921
|
+
console.log(`\n Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
|
|
1772
1922
|
}
|
|
1773
1923
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
+
}
|
|
1777
1986
|
}
|
|
1778
1987
|
}
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
+
}
|
|
1782
2058
|
}
|
|
1783
2059
|
}
|
|
1784
2060
|
else {
|
|
@@ -1802,354 +2078,16 @@ program
|
|
|
1802
2078
|
}
|
|
1803
2079
|
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
1804
2080
|
}
|
|
1805
|
-
|
|
1806
|
-
//
|
|
1807
|
-
|
|
1808
|
-
.
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
.
|
|
1814
|
-
.
|
|
1815
|
-
.option("--data-dir <dir>", "Harper data directory")
|
|
1816
|
-
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
1817
|
-
.option("--skip-smoke", "Skip MCP smoke test")
|
|
1818
|
-
.action(async (opts) => {
|
|
1819
|
-
const httpPort = resolveHttpPort(opts);
|
|
1820
|
-
const opsPort = resolveOpsPort(opts);
|
|
1821
|
-
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
1822
|
-
const dataDir = opts.dataDir ?? defaultDataDir();
|
|
1823
|
-
// Resolve client selection
|
|
1824
|
-
const clientOpt = opts.client;
|
|
1825
|
-
const noMcp = opts.noMcp === true;
|
|
1826
|
-
const selectedClients = [];
|
|
1827
|
-
if (clientOpt === "none" || noMcp) {
|
|
1828
|
-
// Skip MCP entirely — just init + agent
|
|
1829
|
-
}
|
|
1830
|
-
else if (clientOpt === "all") {
|
|
1831
|
-
// Wire all detected clients
|
|
1832
|
-
}
|
|
1833
|
-
else if (clientOpt) {
|
|
1834
|
-
// Wire a specific client
|
|
1835
|
-
const valid = ["claude-code", "codex", "gemini", "cursor"];
|
|
1836
|
-
if (!valid.includes(clientOpt)) {
|
|
1837
|
-
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
|
|
1838
|
-
process.exit(1);
|
|
1839
|
-
}
|
|
1840
|
-
selectedClients.push(clientOpt);
|
|
1841
|
-
}
|
|
1842
|
-
// If no --client flag: interactive detection later
|
|
1843
|
-
// ── Step 1: Ensure Flair is initialized ──
|
|
1844
|
-
let alreadyInitialized = false;
|
|
1845
|
-
let alreadyRunning = false;
|
|
1846
|
-
let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1847
|
-
const adminUser = DEFAULT_ADMIN_USER;
|
|
1848
|
-
// Check if Flair is already initialized (config with port exists)
|
|
1849
|
-
try {
|
|
1850
|
-
const cp = configPath();
|
|
1851
|
-
if (existsSync(cp)) {
|
|
1852
|
-
const yaml = readFileSync(cp, "utf-8");
|
|
1853
|
-
if (yaml.match(/port:\s*\d+/)) {
|
|
1854
|
-
alreadyInitialized = true;
|
|
1855
|
-
console.log("Flair already initialized — skipping init.");
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
catch { /* not initialized */ }
|
|
1860
|
-
if (!alreadyInitialized) {
|
|
1861
|
-
const major = parseInt(process.version.slice(1), 10);
|
|
1862
|
-
if (major < 18)
|
|
1863
|
-
throw new Error(`Node.js >= 18 required (found ${process.version})`);
|
|
1864
|
-
// Only generate a password if none provided via env (fresh install)
|
|
1865
|
-
// If we generate, write it atomically to ~/.flair/admin-pass
|
|
1866
|
-
let adminPassGenerated = false;
|
|
1867
|
-
if (!adminPass) {
|
|
1868
|
-
adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
|
|
1869
|
-
adminPassGenerated = true;
|
|
1870
|
-
// Atomic write: create temp file in same dir, then rename
|
|
1871
|
-
const flairDir = join(homedir(), ".flair");
|
|
1872
|
-
mkdirSync(flairDir, { recursive: true });
|
|
1873
|
-
const adminPassPath = join(flairDir, "admin-pass");
|
|
1874
|
-
const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
|
|
1875
|
-
const finalTempPath = join(tempPath, "admin-pass");
|
|
1876
|
-
try {
|
|
1877
|
-
writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
|
|
1878
|
-
renameSync(finalTempPath, adminPassPath);
|
|
1879
|
-
rmSync(tempPath, { recursive: true, force: true });
|
|
1880
|
-
}
|
|
1881
|
-
catch (err) {
|
|
1882
|
-
// Clean up temp dir on failure
|
|
1883
|
-
try {
|
|
1884
|
-
rmSync(tempPath, { recursive: true, force: true });
|
|
1885
|
-
}
|
|
1886
|
-
catch { }
|
|
1887
|
-
throw err;
|
|
1888
|
-
}
|
|
1889
|
-
console.log(`Admin password saved to: ${adminPassPath}`);
|
|
1890
|
-
}
|
|
1891
|
-
// Check if Harper is already running on this port
|
|
1892
|
-
try {
|
|
1893
|
-
const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
|
|
1894
|
-
if (res.status > 0) {
|
|
1895
|
-
alreadyRunning = true;
|
|
1896
|
-
console.log(`Harper already running on port ${httpPort} — skipping start`);
|
|
1897
|
-
}
|
|
1898
|
-
}
|
|
1899
|
-
catch { /* not running */ }
|
|
1900
|
-
if (!alreadyRunning) {
|
|
1901
|
-
const bin = harperBin();
|
|
1902
|
-
if (!bin)
|
|
1903
|
-
throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
|
|
1904
|
-
mkdirSync(dataDir, { recursive: true });
|
|
1905
|
-
const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
|
|
1906
|
-
const harperSetConfig = JSON.stringify({
|
|
1907
|
-
rootPath: dataDir,
|
|
1908
|
-
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
1909
|
-
operationsApi: { network: { port: opsPort, cors: true }, domainSocket: join(dataDir, "operations-server") },
|
|
1910
|
-
mqtt: { network: { port: null }, webSocket: false },
|
|
1911
|
-
localStudio: { enabled: false },
|
|
1912
|
-
authentication: { authorizeLocal: true, enableSessions: true },
|
|
1913
|
-
});
|
|
1914
|
-
const env = {
|
|
1915
|
-
...process.env,
|
|
1916
|
-
ROOTPATH: dataDir,
|
|
1917
|
-
HARPER_SET_CONFIG: harperSetConfig,
|
|
1918
|
-
DEFAULTS_MODE: "dev",
|
|
1919
|
-
HDB_ADMIN_USERNAME: adminUser,
|
|
1920
|
-
HDB_ADMIN_PASSWORD: adminPass,
|
|
1921
|
-
THREADS_COUNT: "1",
|
|
1922
|
-
NODE_HOSTNAME: "localhost",
|
|
1923
|
-
HTTP_PORT: String(httpPort),
|
|
1924
|
-
OPERATIONSAPI_NETWORK_PORT: String(opsPort),
|
|
1925
|
-
LOCAL_STUDIO: "false",
|
|
1926
|
-
};
|
|
1927
|
-
if (alreadyInstalled) {
|
|
1928
|
-
console.log("Existing Harper installation found — skipping install.");
|
|
1929
|
-
}
|
|
1930
|
-
else {
|
|
1931
|
-
const installEnv = { ...env, HOME: join(dataDir, "..") };
|
|
1932
|
-
console.log("Installing Harper...");
|
|
1933
|
-
console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
|
|
1934
|
-
await new Promise((resolve, reject) => {
|
|
1935
|
-
let output = "";
|
|
1936
|
-
let dotTimer = null;
|
|
1937
|
-
const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
|
|
1938
|
-
dotTimer = setInterval(() => process.stdout.write("."), 3000);
|
|
1939
|
-
install.stdout?.on("data", (d) => { output += d.toString(); });
|
|
1940
|
-
install.stderr?.on("data", (d) => { output += d.toString(); });
|
|
1941
|
-
install.on("exit", (code) => {
|
|
1942
|
-
if (dotTimer) {
|
|
1943
|
-
clearInterval(dotTimer);
|
|
1944
|
-
process.stdout.write("\n");
|
|
1945
|
-
}
|
|
1946
|
-
code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
|
|
1947
|
-
});
|
|
1948
|
-
install.on("error", (err) => {
|
|
1949
|
-
if (dotTimer) {
|
|
1950
|
-
clearInterval(dotTimer);
|
|
1951
|
-
process.stdout.write("\n");
|
|
1952
|
-
}
|
|
1953
|
-
reject(err);
|
|
1954
|
-
});
|
|
1955
|
-
setTimeout(() => {
|
|
1956
|
-
install.kill();
|
|
1957
|
-
if (dotTimer) {
|
|
1958
|
-
clearInterval(dotTimer);
|
|
1959
|
-
process.stdout.write("\n");
|
|
1960
|
-
}
|
|
1961
|
-
reject(new Error(`Harper install timed out: ${output}`));
|
|
1962
|
-
}, 60_000);
|
|
1963
|
-
});
|
|
1964
|
-
}
|
|
1965
|
-
// Start Harper
|
|
1966
|
-
console.log(`Starting Harper on port ${httpPort}...`);
|
|
1967
|
-
const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
|
|
1968
|
-
proc.unref();
|
|
1969
|
-
}
|
|
1970
|
-
// Wait for health
|
|
1971
|
-
console.log("Waiting for Harper health check...");
|
|
1972
|
-
await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
|
|
1973
|
-
console.log("Harper is healthy ✓");
|
|
1974
|
-
// Write config so other commands can find this instance
|
|
1975
|
-
writeConfig(httpPort);
|
|
1976
|
-
}
|
|
1977
|
-
else {
|
|
1978
|
-
// Flair already initialized — resolve admin pass from env, falling back to
|
|
1979
|
-
// the persisted ~/.flair/admin-pass written at first install. Agent
|
|
1980
|
-
// seeding now goes through the ops API (#499), which always requires Basic
|
|
1981
|
-
// admin auth (it does NOT honor authorizeLocal), so a re-run of
|
|
1982
|
-
// `flair install --agent <new-id>` against an already-initialized local
|
|
1983
|
-
// Flair needs a real pass even when no env var is set.
|
|
1984
|
-
adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1985
|
-
if (!adminPass) {
|
|
1986
|
-
try {
|
|
1987
|
-
const persisted = join(homedir(), ".flair", "admin-pass");
|
|
1988
|
-
if (existsSync(persisted))
|
|
1989
|
-
adminPass = readFileSync(persisted, "utf-8").trim();
|
|
1990
|
-
}
|
|
1991
|
-
catch { /* fall through with empty pass */ }
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1995
|
-
// ── Step 2: Detect MCP clients ──
|
|
1996
|
-
let clients = detectClients();
|
|
1997
|
-
if (selectedClients.length > 0) {
|
|
1998
|
-
// Explicit --client flag — override detection
|
|
1999
|
-
if (clientOpt === "all" || clientOpt === "none" || noMcp) {
|
|
2000
|
-
// all/none handled below
|
|
2001
|
-
}
|
|
2002
|
-
else {
|
|
2003
|
-
// Filter to only the selected client (preserving wire function from detectClients)
|
|
2004
|
-
clients = clients.filter(c => selectedClients.includes(c.id));
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2007
|
-
if (!clientOpt && !noMcp) {
|
|
2008
|
-
// No --client flag — print detected clients
|
|
2009
|
-
const detected = clients.filter(c => c.detected);
|
|
2010
|
-
if (detected.length === 0) {
|
|
2011
|
-
console.log("No MCP clients detected. Run with --client <name> to wire a specific client.");
|
|
2012
|
-
}
|
|
2013
|
-
else {
|
|
2014
|
-
console.log(`Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
|
|
2015
|
-
}
|
|
2016
|
-
}
|
|
2017
|
-
// ── Step 3: Resolve agent identity ──
|
|
2018
|
-
const agentId = opts.agent ?? (() => {
|
|
2019
|
-
try {
|
|
2020
|
-
const hn = hostname();
|
|
2021
|
-
return hn.split(".")[0];
|
|
2022
|
-
}
|
|
2023
|
-
catch {
|
|
2024
|
-
return "flair-agent";
|
|
2025
|
-
}
|
|
2026
|
-
})();
|
|
2027
|
-
// Validate agentId to prevent path traversal
|
|
2028
|
-
const VALID_AGENT_ID = /^[a-zA-Z0-9_-]+$/;
|
|
2029
|
-
if (!VALID_AGENT_ID.test(agentId)) {
|
|
2030
|
-
throw new Error(`Invalid agent ID: ${agentId}. Agent ID must contain only letters, numbers, underscores, and hyphens.`);
|
|
2031
|
-
}
|
|
2032
|
-
let agentExists = false;
|
|
2033
|
-
// Check if agent already exists locally
|
|
2034
|
-
const privPath = privKeyPath(agentId, keysDir);
|
|
2035
|
-
if (existsSync(privPath)) {
|
|
2036
|
-
agentExists = true;
|
|
2037
|
-
console.log(`Agent '${agentId}' already exists ✓`);
|
|
2038
|
-
}
|
|
2039
|
-
else {
|
|
2040
|
-
// Create agent
|
|
2041
|
-
mkdirSync(keysDir, { recursive: true });
|
|
2042
|
-
const pubPath = pubKeyPath(agentId, keysDir);
|
|
2043
|
-
console.log("Generating Ed25519 keypair...");
|
|
2044
|
-
const kp = nacl.sign.keyPair();
|
|
2045
|
-
const seed = kp.secretKey.slice(0, 32);
|
|
2046
|
-
writeFileSync(privPath, Buffer.from(seed));
|
|
2047
|
-
chmodSync(privPath, 0o600);
|
|
2048
|
-
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
2049
|
-
const pubKeyB64url = b64url(kp.publicKey);
|
|
2050
|
-
console.log(`Keypair written: ${privPath} ✓`);
|
|
2051
|
-
// Seed agent via the Harper operations API. The Agent table is a REST
|
|
2052
|
-
// resource without a POST handler, so the insert body must go to the ops
|
|
2053
|
-
// API (POST <opsUrl>/ with {operation:"insert",...}), NOT the REST root
|
|
2054
|
-
// (which 405s the body as a collection POST to /Agent). This is the same
|
|
2055
|
-
// path `flair agent add` uses. (#499)
|
|
2056
|
-
const seedOpsTarget = opts.opsTarget ?? opsPort;
|
|
2057
|
-
console.log(opts.opsTarget
|
|
2058
|
-
? `Seeding agent '${agentId}' via operations API (--ops-target)...`
|
|
2059
|
-
: `Seeding agent '${agentId}' via operations API...`);
|
|
2060
|
-
await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, adminUser, adminPass);
|
|
2061
|
-
console.log(`Agent '${agentId}' registered ✓`);
|
|
2062
|
-
}
|
|
2063
|
-
// ── Step 4: Wire MCP clients ──
|
|
2064
|
-
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
2065
|
-
const wiringResults = [];
|
|
2066
|
-
if (!noMcp && clientOpt !== "none") {
|
|
2067
|
-
const toWire = clientOpt === "all"
|
|
2068
|
-
? clients.filter(c => c.detected).map(c => c.id)
|
|
2069
|
-
: selectedClients.length > 0
|
|
2070
|
-
? selectedClients
|
|
2071
|
-
: clients.filter(c => c.detected).map(c => c.id);
|
|
2072
|
-
for (const clientId of toWire) {
|
|
2073
|
-
let result;
|
|
2074
|
-
switch (clientId) {
|
|
2075
|
-
case "claude-code":
|
|
2076
|
-
result = wireClaudeCode(mcpEnv);
|
|
2077
|
-
break;
|
|
2078
|
-
case "codex":
|
|
2079
|
-
result = wireCodex(mcpEnv);
|
|
2080
|
-
break;
|
|
2081
|
-
case "gemini":
|
|
2082
|
-
result = wireGemini(mcpEnv);
|
|
2083
|
-
break;
|
|
2084
|
-
case "cursor":
|
|
2085
|
-
result = wireCursor(mcpEnv);
|
|
2086
|
-
break;
|
|
2087
|
-
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
2088
|
-
}
|
|
2089
|
-
wiringResults.push({ client: clientId, message: result.message });
|
|
2090
|
-
console.log(` ${result.ok ? "✓" : "✗"} ${result.message}`);
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
// ── Step 5: Smoke test the MCP server ──
|
|
2094
|
-
if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
|
|
2095
|
-
console.log("Smoke-testing MCP server...");
|
|
2096
|
-
try {
|
|
2097
|
-
// Launch flair-mcp and send initialize request over stdio
|
|
2098
|
-
const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
|
|
2099
|
-
env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
|
|
2100
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
2101
|
-
timeout: 15_000,
|
|
2102
|
-
});
|
|
2103
|
-
// Send initialize request
|
|
2104
|
-
const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-install", version: "1.0.0" } } });
|
|
2105
|
-
mcpProc.stdin.write(initMsg + "\n");
|
|
2106
|
-
mcpProc.stdin.end();
|
|
2107
|
-
let stdout = "";
|
|
2108
|
-
mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
2109
|
-
await new Promise((resolve, reject) => {
|
|
2110
|
-
mcpProc.on("exit", (code) => {
|
|
2111
|
-
if (code === 0 && stdout.length > 0) {
|
|
2112
|
-
resolve();
|
|
2113
|
-
}
|
|
2114
|
-
else {
|
|
2115
|
-
reject(new Error(`MCP server exited with code ${code}`));
|
|
2116
|
-
}
|
|
2117
|
-
});
|
|
2118
|
-
mcpProc.on("error", reject);
|
|
2119
|
-
setTimeout(() => { mcpProc.kill(); reject(new Error("MCP smoke test timed out")); }, 15_000);
|
|
2120
|
-
});
|
|
2121
|
-
// Check for a valid JSON-RPC response
|
|
2122
|
-
try {
|
|
2123
|
-
const lines = stdout.split("\n").filter(l => l.trim());
|
|
2124
|
-
for (const line of lines) {
|
|
2125
|
-
const parsed = JSON.parse(line);
|
|
2126
|
-
if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
|
|
2127
|
-
console.log(" ✓ MCP server responded");
|
|
2128
|
-
break;
|
|
2129
|
-
}
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
|
-
catch {
|
|
2133
|
-
console.log(" ⚠ MCP server responded but response could not be parsed");
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
catch (err) {
|
|
2137
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2138
|
-
console.log(` ⚠ MCP smoke test failed: ${message}`);
|
|
2139
|
-
console.log(" Use --skip-smoke to bypass.");
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
// ── Step 6: Summary ──
|
|
2143
|
-
console.log("");
|
|
2144
|
-
console.log("✓ Flair installed.");
|
|
2145
|
-
console.log(` Agent: ${agentId}`);
|
|
2146
|
-
if (existsSync(privKeyPath(agentId, keysDir))) {
|
|
2147
|
-
console.log(` Private key: ${privKeyPath(agentId, keysDir)}`);
|
|
2148
|
-
}
|
|
2149
|
-
console.log(` Local: ${httpUrl}`);
|
|
2150
|
-
console.log(` MCP: ${wiringResults.length > 0 ? wiringResults.map(r => r.client).join(", ") + " ✓ wired" : "none wired"}`);
|
|
2151
|
-
console.log("");
|
|
2152
|
-
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);
|
|
2153
2091
|
});
|
|
2154
2092
|
// ─── flair agent ─────────────────────────────────────────────────────────────
|
|
2155
2093
|
const agent = program.command("agent").description("Manage Flair agents");
|
|
@@ -2209,6 +2147,8 @@ agent
|
|
|
2209
2147
|
.command("list")
|
|
2210
2148
|
.description("List all agents")
|
|
2211
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")
|
|
2212
2152
|
.option("--port <port>", "Harper HTTP port")
|
|
2213
2153
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
2214
2154
|
.action(async (opts) => {
|
|
@@ -2244,13 +2184,39 @@ agent
|
|
|
2244
2184
|
agents = await res.json();
|
|
2245
2185
|
}
|
|
2246
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.
|
|
2247
2192
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
2248
|
-
const
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
+
}
|
|
2251
2210
|
if (!res.ok) {
|
|
2252
2211
|
const text = await res.text().catch(() => "");
|
|
2253
|
-
|
|
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
|
+
}
|
|
2254
2220
|
process.exit(1);
|
|
2255
2221
|
}
|
|
2256
2222
|
const data = await res.json();
|
|
@@ -6718,6 +6684,7 @@ program
|
|
|
6718
6684
|
.command("doctor")
|
|
6719
6685
|
.description("Diagnose common Flair problems and suggest fixes")
|
|
6720
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)")
|
|
6721
6688
|
.option("--fix", "Automatically fix issues where possible")
|
|
6722
6689
|
.option("--dry-run", "Show what --fix would do without making changes")
|
|
6723
6690
|
.action(async (opts) => {
|
|
@@ -6880,32 +6847,38 @@ program
|
|
|
6880
6847
|
else {
|
|
6881
6848
|
console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
|
|
6882
6849
|
}
|
|
6883
|
-
// 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.
|
|
6884
6860
|
if (harperResponding) {
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
}
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
console.log(` ${render.icons.warn} Embeddings: cannot verify ${render.wrap(render.c.dim, "(auth required for SemanticSearch)")}`);
|
|
6906
|
-
}
|
|
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;
|
|
6907
6881
|
}
|
|
6908
|
-
catch { /* fetch error, already flagged */ }
|
|
6909
6882
|
}
|
|
6910
6883
|
// 5. Stale PID file (skip if already reported in port check)
|
|
6911
6884
|
const dataDir = defaultDataDir();
|