@tpsdev-ai/flair 0.42.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import { probeInstance } from "./probe.js";
20
20
  import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
21
21
  import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
22
22
  import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
23
- import { flairCliVersion, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
23
+ import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
24
24
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
25
25
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
26
26
  import { readClientMcpBlock, checkClaudeMdBootstrap, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, inferSoleAgentId, fixCommandAgentHint, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
@@ -136,6 +136,7 @@ function shouldShowInlineSecretWarning(optValue, fromEnv, secretFlagNames, flagN
136
136
  // ─── Defaults ────────────────────────────────────────────────────────────────
137
137
  const DEFAULT_PORT = 19926;
138
138
  const DEFAULT_OPS_PORT = 19925;
139
+ const FABRIC_OPS_PORT = 9925;
139
140
  const DEFAULT_ADMIN_USER = "admin";
140
141
  const STARTUP_TIMEOUT_MS = 60_000;
141
142
  const HEALTH_POLL_INTERVAL_MS = 500;
@@ -1076,10 +1077,15 @@ function resolveOpsTarget(opts) {
1076
1077
  return opts.opsTarget || process.env.FLAIR_OPS_TARGET || undefined;
1077
1078
  }
1078
1079
  /** Derive the ops API URL from a Flair base URL.
1079
- * Convention: ops port = HTTP port - 1.
1080
- * If target has an explicit port, use port-1 (validated: must be 1-65535).
1081
- * If no explicit port: https → 442 (443-1), http → 19925 (19926-1), bare host → https://<host>:19925.
1082
- * Throws on unparseable URLs.
1080
+ * https with effective port 443 (no explicit port, or explicit :443): returns
1081
+ * <host>:9925 (FABRIC_OPS_PORT) the Fabric managed case where port-1/:442
1082
+ * is a dead-end.
1083
+ * All other cases unchanged:
1084
+ * https with non-443 explicit port → port-1 (self-hosted TLS: 19926→19925, 8443→8442)
1085
+ * http with explicit port → port-1 (19926→19925)
1086
+ * http with no port → DEFAULT_OPS_PORT (19925)
1087
+ * Bare hosts are normalised to https:// (effective-443 → Fabric path).
1088
+ * Throws on unparseable URLs or out-of-range ports.
1083
1089
  */
1084
1090
  /** Compute the effective ops API URL for remote commands.
1085
1091
  * - If --ops-target is set, use it directly (no derivation).
@@ -1099,6 +1105,17 @@ function resolveOpsUrlFromTarget(targetUrl) {
1099
1105
  // Normalise bare hosts: add https:// prefix so URL parser can handle them.
1100
1106
  const normalised = targetUrl.includes("://") ? targetUrl : `https://${targetUrl}`;
1101
1107
  const url = new URL(normalised);
1108
+ // https target with effective port 443 (no explicit port, or explicit :443):
1109
+ // this is the Fabric managed case — the ops API is on the well-known Fabric
1110
+ // ops port, never REST-adjacent. The port-1 / :442 logic is a dead-end here.
1111
+ if (url.protocol === "https:" && (url.port === "" || url.port === "443")) {
1112
+ url.port = String(FABRIC_OPS_PORT);
1113
+ return url.toString().replace(/\/$/, "");
1114
+ }
1115
+ // All other cases: unchanged port-1 convention.
1116
+ // https with non-443 explicit port → port-1 (self-hosted TLS: 19926→19925, 8443→8442)
1117
+ // http with explicit port → port-1 (19926→19925)
1118
+ // http with no port → DEFAULT_OPS_PORT (19925)
1102
1119
  const port = parseInt(url.port, 10);
1103
1120
  if (!isNaN(port) && port > 0 && port <= 65535) {
1104
1121
  const opsPort = port - 1;
@@ -1111,13 +1128,8 @@ function resolveOpsUrlFromTarget(targetUrl) {
1111
1128
  if (url.port !== "" && url.port !== undefined) {
1112
1129
  throw new Error(`Invalid target port: ${url.port} (must be 1-65535)`);
1113
1130
  }
1114
- // No explicit port — infer from scheme
1115
- if (url.protocol === "https:") {
1116
- url.port = "442";
1117
- }
1118
- else {
1119
- url.port = String(DEFAULT_OPS_PORT);
1120
- }
1131
+ // No explicit port on http use the default ops port.
1132
+ url.port = String(DEFAULT_OPS_PORT);
1121
1133
  return url.toString().replace(/\/$/, "");
1122
1134
  }
1123
1135
  /**
@@ -1782,12 +1794,21 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
1782
1794
  export async function callOpsApi(opsUrl, body, user, pass) {
1783
1795
  const url = `${opsUrl.replace(/\/$/, "")}/`;
1784
1796
  const auth = Buffer.from(`${user}:${pass}`).toString("base64");
1785
- const res = await fetch(url, {
1786
- method: "POST",
1787
- headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
1788
- body: JSON.stringify(body),
1789
- signal: AbortSignal.timeout(30_000),
1790
- });
1797
+ let res;
1798
+ try {
1799
+ res = await fetch(url, {
1800
+ method: "POST",
1801
+ headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
1802
+ body: JSON.stringify(body),
1803
+ signal: AbortSignal.timeout(30_000),
1804
+ });
1805
+ }
1806
+ catch (err) {
1807
+ const message = err instanceof Error ? err.message : String(err);
1808
+ throw new Error(`ops API unreachable at ${opsUrl} (derived from --target). ` +
1809
+ `Set --ops-target or FLAIR_OPS_TARGET to override. ` +
1810
+ `(${message})`);
1811
+ }
1791
1812
  if (!res.ok) {
1792
1813
  const text = await res.text().catch(() => "");
1793
1814
  throw new Error(`Ops API call failed (${res.status}): ${text}`);
@@ -2297,6 +2318,25 @@ export function shouldPrintUpgradeLine(status, showAll) {
2297
2318
  return false;
2298
2319
  return true;
2299
2320
  }
2321
+ /**
2322
+ * Returns the human-readable suffix for a package status line in
2323
+ * `flair upgrade` / `flair upgrade --check` output.
2324
+ *
2325
+ * flair-mcp is zero-install via npx — its suffix must never suggest a
2326
+ * global install (flair#1168).
2327
+ */
2328
+ export function upgradeStatusSuffix(name, status) {
2329
+ if (status === "current")
2330
+ return " (current)";
2331
+ if (status === "missing") {
2332
+ return name === "@tpsdev-ai/flair-mcp"
2333
+ ? " (zero-install via npx — run: flair doctor --fix)"
2334
+ : " (run: npm install -g)";
2335
+ }
2336
+ if (status === "optional")
2337
+ return " (install via: openclaw plugins install @tpsdev-ai/openclaw-flair)";
2338
+ return "";
2339
+ }
2300
2340
  /**
2301
2341
  * Pure flag resolution for `flair upgrade`'s restart/verify defaults
2302
2342
  * (flair#635 decision: restart is now the default; `--no-restart` opts
@@ -9430,10 +9470,7 @@ program
9430
9470
  : status === "optional" ? "○"
9431
9471
  : "❔";
9432
9472
  const installedLabel = installed ?? (status === "optional" ? "not installed (openclaw not detected)" : "not detected");
9433
- const suffix = status === "current" ? " (current)"
9434
- : status === "missing" ? " (run: npm install -g)"
9435
- : status === "optional" ? " (install via: openclaw plugins install @tpsdev-ai/openclaw-flair)"
9436
- : "";
9473
+ const suffix = upgradeStatusSuffix(name, status);
9437
9474
  console.log(` ${icon} ${name}: ${installedLabel} → ${latest}${suffix}`);
9438
9475
  }
9439
9476
  catch { /* skip unavailable packages */ }
@@ -9459,8 +9496,15 @@ program
9459
9496
  return;
9460
9497
  }
9461
9498
  if (missing.length > 0 && outdated.length === 0) {
9499
+ const npmMissing = missing.filter((f) => f.name !== "@tpsdev-ai/flair-mcp");
9500
+ const mcpMissing = missing.filter((f) => f.name === "@tpsdev-ai/flair-mcp");
9462
9501
  console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
9463
- console.log(` Install missing: npm install -g ${missing.map((f) => f.name).join(" ")}`);
9502
+ if (npmMissing.length > 0) {
9503
+ console.log(` Install missing: npm install -g ${npmMissing.map((f) => f.name).join(" ")}`);
9504
+ }
9505
+ if (mcpMissing.length > 0) {
9506
+ console.log(` flair-mcp is zero-install via npx — run: flair doctor --fix to re-wire the hook`);
9507
+ }
9464
9508
  return;
9465
9509
  }
9466
9510
  if (checkOnly) {
@@ -9669,6 +9713,61 @@ program
9669
9713
  console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
9670
9714
  }
9671
9715
  }
9716
+ // flair#1167: `npm install -g` replaced package.json in-place, so the
9717
+ // module-load-cached CLI version is stale. Clear it so mcpServerSpec()
9718
+ // resolves the NEW version for the pin refresh below.
9719
+ clearFlairCliVersionCache();
9720
+ // ── Refresh wired MCP client configs (flair#1135, flair#1167) ──────────
9721
+ // After a successful package install, the flair-mcp package on disk is
9722
+ // newer than the pinned version in wired client configs. Re-run wiring for
9723
+ // already-wired clients so the pin stays in lockstep with the installed
9724
+ // version. Runs BEFORE the restart so --no-restart and --no-verify paths
9725
+ // also get the refresh (flair#1167). Best-effort: failures warn but never
9726
+ // fail the upgrade.
9727
+ await (async () => {
9728
+ const agentId = resolveAgentIdOrEnv({}) ?? (() => {
9729
+ try {
9730
+ const keyFiles = readdirSync(defaultKeysDir()).filter((f) => f.endsWith(".key"));
9731
+ return keyFiles.length > 0 ? keyFiles[0].replace(/\.key$/, "") : null;
9732
+ }
9733
+ catch {
9734
+ return null;
9735
+ }
9736
+ })();
9737
+ if (!agentId) {
9738
+ console.log("\n (no agent id known — skip MCP client pin refresh; run `flair init` to refresh manually)");
9739
+ return;
9740
+ }
9741
+ const httpUrl = `http://127.0.0.1:${upgradePort}`;
9742
+ const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
9743
+ const detected = detectClients().filter(c => c.detected);
9744
+ if (detected.length === 0)
9745
+ return;
9746
+ console.log("\n Refreshing MCP client pins...");
9747
+ for (const client of detected) {
9748
+ const configPath = clientConfigPath(client.id);
9749
+ if (!existsSync(configPath))
9750
+ continue;
9751
+ // Only refresh clients that are already wired — don't wire new ones.
9752
+ let hasFlair = false;
9753
+ try {
9754
+ const raw = readFileSync(configPath, "utf-8");
9755
+ if (client.id === "codex") {
9756
+ hasFlair = codexConfigHasFlairSection(raw);
9757
+ }
9758
+ else {
9759
+ const cfg = JSON.parse(raw);
9760
+ hasFlair = !!cfg.mcpServers?.flair;
9761
+ }
9762
+ }
9763
+ catch { /* unreadable/malformed — skip */ }
9764
+ if (!hasFlair)
9765
+ continue;
9766
+ const env = { ...mcpEnv, FLAIR_CLIENT: client.id };
9767
+ const result = client.wire(env);
9768
+ console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
9769
+ }
9770
+ })();
9672
9771
  // ── Restart + verify + rollback (flair#635) ─────────────────────────────
9673
9772
  // Decision (2026-07-08): restart is now the default post-upgrade step —
9674
9773
  // installing new code without restarting leaves the OLD process serving
@@ -9887,55 +9986,6 @@ program
9887
9986
  authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
9888
9987
  });
9889
9988
  const verdict = decideAfterVerify(verify, previousFlairVersion);
9890
- // ── Refresh wired MCP client configs (flair#1135) ──────────────────────
9891
- // After a successful upgrade, the flair-mcp package on disk is newer than
9892
- // the pinned version in wired client configs. Re-run wiring for
9893
- // already-wired clients so the pin stays in lockstep with the installed
9894
- // version. Best-effort: failures warn but never fail the upgrade.
9895
- const refreshWiredClients = async () => {
9896
- const agentId = resolveAgentIdOrEnv({}) ?? (() => {
9897
- try {
9898
- const keyFiles = readdirSync(defaultKeysDir()).filter((f) => f.endsWith(".key"));
9899
- return keyFiles.length > 0 ? keyFiles[0].replace(/\.key$/, "") : null;
9900
- }
9901
- catch {
9902
- return null;
9903
- }
9904
- })();
9905
- if (!agentId) {
9906
- console.log("\n (no agent id known — skip MCP client pin refresh; run `flair init` to refresh manually)");
9907
- return;
9908
- }
9909
- const httpUrl = `http://127.0.0.1:${upgradePort}`;
9910
- const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
9911
- const detected = detectClients().filter(c => c.detected);
9912
- if (detected.length === 0)
9913
- return;
9914
- console.log("\n Refreshing MCP client pins...");
9915
- for (const client of detected) {
9916
- const configPath = clientConfigPath(client.id);
9917
- if (!existsSync(configPath))
9918
- continue;
9919
- // Only refresh clients that are already wired — don't wire new ones.
9920
- let hasFlair = false;
9921
- try {
9922
- const raw = readFileSync(configPath, "utf-8");
9923
- if (client.id === "codex") {
9924
- hasFlair = codexConfigHasFlairSection(raw);
9925
- }
9926
- else {
9927
- const cfg = JSON.parse(raw);
9928
- hasFlair = !!cfg.mcpServers?.flair;
9929
- }
9930
- }
9931
- catch { /* unreadable/malformed — skip */ }
9932
- if (!hasFlair)
9933
- continue;
9934
- const env = { ...mcpEnv, FLAIR_CLIENT: client.id };
9935
- const result = client.wire(env);
9936
- console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
9937
- }
9938
- };
9939
9989
  if (verdict.kind === "ok") {
9940
9990
  // flair#1022: the verified facts are unchanged and still stated — the
9941
9991
  // upgrade did land. What changes is the MARKER and the claim around it.
@@ -9951,7 +10001,6 @@ program
9951
10001
  else
9952
10002
  console.log(line);
9953
10003
  }
9954
- await refreshWiredClients();
9955
10004
  return;
9956
10005
  }
9957
10006
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -9978,7 +10027,6 @@ program
9978
10027
  console.error(line);
9979
10028
  }
9980
10029
  }
9981
- await refreshWiredClients();
9982
10030
  return;
9983
10031
  }
9984
10032
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -12085,22 +12133,34 @@ program
12085
12133
  const hook = inspectSessionStartHook(homedir());
12086
12134
  if (hook.present) {
12087
12135
  if (hook.execution === "broken") {
12088
- // Reported in full, with the remedy but NOT counted as an issue,
12089
- // so it never flips doctor's exit code on its own. This is a
12090
- // verification of the environment at the moment doctor runs (a
12091
- // cold `npx` cache, an offline machine, a slow registry), exactly
12092
- // like the FLAIR_URL reachability and agent-registration
12093
- // verifications above, which are warnings for the same reason. A
12094
- // fresh, correct install on a machine that simply has not fetched
12095
- // the adapter yet must not be told it is broken in the exit code.
12096
- // The unsilenced finding below IS counted: that one is a fact
12097
- // about the file, true regardless of the environment.
12098
- console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
12099
- console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
12100
- console.log(` ${render.wrap(render.c.dim, "If this persists, the Node runtime probably changed and the globally")}`);
12101
- console.log(` ${render.wrap(render.c.dim, "installed @tpsdev-ai/flair-mcp no longer resolves for it.")}`);
12102
- console.log(` ${render.wrap(render.c.dim, "Fix:")} npm install -g @tpsdev-ai/flair-mcp ${render.wrap(render.c.dim, "(reinstall for the runtime you use now)")}`);
12103
- console.log(` ${render.wrap(render.c.dim, "Or, if you no longer want ambient memory:")} flair hook uninstall`);
12136
+ // Two very different states that share one probe outcome:
12137
+ //
12138
+ // 1. Silenced (current) command that didn't run the npx cache
12139
+ // is cold, the machine is offline, or the adapter hasn't been
12140
+ // fetched yet. On a fresh install this is NORMAL: the hook is
12141
+ // wired but no Claude Code session has exercised it yet.
12142
+ // Report as informational, not a warning, and never suggest
12143
+ // reinstall the setup is correct, the environment just
12144
+ // hasn't warmed yet.
12145
+ //
12146
+ // 2. Unsilenced (legacy) command that didn't run the hook has
12147
+ // been in place long enough that a cold cache is not the
12148
+ // explanation. This IS a genuine failure: warn and name the
12149
+ // actual state with a fitting remedy.
12150
+ if (hook.silenced) {
12151
+ console.log(` ${render.icons.ok} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)} not yet exercised`);
12152
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
12153
+ console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
12154
+ console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Claude Code session will warm the npx cache.")}`);
12155
+ }
12156
+ else {
12157
+ console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
12158
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
12159
+ console.log(` ${render.wrap(render.c.dim, "The hook command could not be executed. Check that npx can resolve")}`);
12160
+ console.log(` ${render.wrap(render.c.dim, "@tpsdev-ai/flair-mcp — a cold npx cache or network issue")}`);
12161
+ console.log(` ${render.wrap(render.c.dim, "issue can prevent the adapter from running on its first invocation.")}`);
12162
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites the hook to the current silent-failure form)")}`);
12163
+ }
12104
12164
  }
12105
12165
  else if (hook.execution === "unknown") {
12106
12166
  console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
@@ -16058,7 +16118,7 @@ if (import.meta.main) {
16058
16118
  // ─── Exported for testing ─────────────────────────────────────────────────────
16059
16119
  export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
16060
16120
  // Harper's own config — the per-instance port record (flair#914)
16061
- harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, 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, resolveLocalAdminPass, readAdminPassFileSecure,
16121
+ harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, FABRIC_OPS_PORT, 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, resolveLocalAdminPass, readAdminPassFileSecure,
16062
16122
  // launchd label (flair#693)
16063
16123
  LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
16064
16124
  // launchd management observation (flair#1022)
@@ -41,7 +41,7 @@ export const SESSION_START_HOOK_MARKER = "flair-session-start";
41
41
  //
42
42
  // WHY THE INVOCATION IS WRAPPED
43
43
  // -----------------------------
44
- // The hook runs `npx -y @tpsdev-ai/flair-mcp flair-session-start`: it resolves
44
+ // The hook runs `npx -y -p @tpsdev-ai/flair-mcp flair-session-start`: it resolves
45
45
  // a package binary through whatever Node runtime the user's shell happens to
46
46
  // expose. Under a Node version manager, globally installed packages are
47
47
  // per-runtime-version, so a routine and entirely unrelated runtime upgrade
@@ -117,7 +117,7 @@ export function buildSessionStartHookCommand(agentId, flairUrl) {
117
117
  throw new Error(`Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
118
118
  }
119
119
  const env = flairUrl ? `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl}` : `FLAIR_AGENT_ID=${agentId}`;
120
- const invocation = `${env} npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
120
+ const invocation = `${env} npx -y -p @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
121
121
  return `sh -c 'out=$(${invocation} 2>/dev/null) && printf %s "$out" || true'`;
122
122
  }
123
123
  /**
@@ -525,6 +525,18 @@ export function inspectSessionStartHook(homeDir, opts = {}) {
525
525
  const verdict = classifyHookProbe(outcome);
526
526
  return { path: found.path, present: true, command, ours, silenced, upgradable, execution: verdict.execution, detail: verdict.detail };
527
527
  }
528
+ export function classifyHookReadiness(report) {
529
+ if (!report.present)
530
+ return "absent";
531
+ if (!report.ours)
532
+ return "custom";
533
+ if (report.execution === "runs")
534
+ return "runs";
535
+ if (report.execution === "broken") {
536
+ return report.silenced ? "not-yet-exercised" : "genuinely-broken";
537
+ }
538
+ return "unverified";
539
+ }
528
540
  /**
529
541
  * Rewrite an existing Flair-authored hook command to the current canonical
530
542
  * form, in place, preserving the agent id and URL the entry already carries —
@@ -344,7 +344,7 @@ export function hookStatus(homeDir, harness) {
344
344
  }
345
345
  const hookEntry = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex];
346
346
  const command = typeof hookEntry?.command === "string" ? hookEntry.command : "";
347
- const correctShape = hookEntry?.type === "command" && command.includes(`npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`);
347
+ const correctShape = hookEntry?.type === "command" && command.includes(`npx -y -p @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`);
348
348
  const env = parseHookCommandEnv(command);
349
349
  return {
350
350
  harness, path, wired: true, correctShape,
@@ -286,10 +286,12 @@ export function readSigningKeyFile(path) {
286
286
  /**
287
287
  * The `@harperfast/oauth` config block, matching the installed 2.2.0
288
288
  * package's field names (node_modules/@harperfast/oauth/dist/types.d.ts).
289
- * Secrets are `${ENV_VAR}` placeholders — never literal values — so this
290
- * block is safe to write to harperdb-config.yaml via `set_configuration`
291
- * (the config file itself carries no secret material; see the
292
- * secrets-provisioning step for how the referenced env vars land).
289
+ * Secrets are `${ENV_VAR}` placeholders — never literal values.
290
+ *
291
+ * flair#1136: set_configuration delivery was removed. Fabric regenerates
292
+ * the root harperdb-config.yaml; the component's own config.yaml is the
293
+ * source of truth for the oauth block. This function builds the block that
294
+ * ships in config.yaml — it is never written to harperdb-config.yaml.
293
295
  *
294
296
  * flair#756: `dynamicClientRegistration: { enabled: false }` is written
295
297
  * EXPLICITLY — never omitted. See the module header's "Leaving
@@ -634,32 +636,7 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
634
636
  }
635
637
  return { principalCreated, credentialId, credentialReused: Boolean(existing) };
636
638
  }
637
- /** `set_configuration` (writes harperdb-config.yaml) then `restart`
638
- * (whole-process restart) — the genuine Harper Operations API operations
639
- * this module's header documents. Throws on either non-2xx response. */
640
- export async function applyRemoteConfigAndRestart(params, deps = {}) {
641
- const fetchImpl = deps.fetchImpl ?? fetch;
642
- const opsUrl = opsBaseUrl(params.opsPortOrUrl);
643
- const authHeader = basicAuthHeader(params.adminUser, params.adminPass);
644
- const setRes = await fetchImpl(opsUrl, {
645
- method: "POST",
646
- headers: { "Content-Type": "application/json", Authorization: authHeader },
647
- body: JSON.stringify({ operation: "set_configuration", ...params.configBlock }),
648
- });
649
- if (!setRes.ok) {
650
- const text = await setRes.text().catch(() => "");
651
- throw new Error(`set_configuration failed (HTTP ${setRes.status}): ${text}`);
652
- }
653
- const restartRes = await fetchImpl(opsUrl, {
654
- method: "POST",
655
- headers: { "Content-Type": "application/json", Authorization: authHeader },
656
- body: JSON.stringify({ operation: "restart" }),
657
- });
658
- if (!restartRes.ok) {
659
- const text = await restartRes.text().catch(() => "");
660
- throw new Error(`restart failed (HTTP ${restartRes.status}): ${text}`);
661
- }
662
- }
639
+ // ─── Restart only ────────────────────────────────────────────────────────────
663
640
  /** `restart` only — used by `disableMcp` (flag off + restart, no config
664
641
  * rewrite: the `@harperfast/oauth` config block is left in place; it is
665
642
  * inert whenever `FLAIR_MCP_OAUTH` is unset, per the byte-identical-boot
@@ -55,8 +55,14 @@ export function resolveFlairCliVersion(startDir) {
55
55
  let cachedVersion;
56
56
  /**
57
57
  * This CLI's own version — the single source for `--version`, the CLI↔server
58
- * handshake, `upgrade --check`, and the MCP pin. Cached: the answer cannot
59
- * change within a process, and several commands ask repeatedly.
58
+ * handshake, `upgrade --check`, and the MCP pin. Cached: the answer normally
59
+ * cannot change within a process, and several commands ask repeatedly.
60
+ *
61
+ * The one exception is `flair upgrade`: `npm install -g` replaces package.json
62
+ * in-place while the process is still running, so the cached version becomes
63
+ * stale. Callers that run after an in-process upgrade must call
64
+ * {@link clearFlairCliVersionCache} first so the next read resolves the new
65
+ * version from disk (flair#1167).
60
66
  */
61
67
  export function flairCliVersion() {
62
68
  if (cachedVersion === undefined) {
@@ -64,6 +70,14 @@ export function flairCliVersion() {
64
70
  }
65
71
  return cachedVersion;
66
72
  }
73
+ /**
74
+ * Clear the cached CLI version so the next call to {@link flairCliVersion}
75
+ * re-resolves from the package.json on disk. Needed after an in-process
76
+ * `npm install -g` replaces the package (flair#1167).
77
+ */
78
+ export function clearFlairCliVersionCache() {
79
+ cachedVersion = undefined;
80
+ }
67
81
  /** True when `version` is usable as a pin. */
68
82
  export function isResolvedVersion(version) {
69
83
  return !!version && version !== UNKNOWN_VERSION;
@@ -129,14 +129,17 @@ where nothing answers:
129
129
  # --target https://<fabric-node>:19926/<instance> → ops derived as :19925 ✓
130
130
  ```
131
131
 
132
+ **Fabric's ops API runs on the same hostname at port 9925** <!-- docs-freshness-allow: Fabric ops API port, not legacy data port --> (the deploy/upgrade path already targets this port). For a managed `*.harperfabric.com` instance:
133
+
134
+ ```bash
135
+ # Same hostname, port 9925 — not port 442 <!-- docs-freshness-allow: Fabric ops API -->
136
+ flair init --target https://<cluster>.<org>.harperfabric.com \
137
+ --ops-target https://<cluster>.<org>.harperfabric.com:9925 <!-- docs-freshness-allow: Fabric ops API -->
138
+ ```
139
+
132
140
  **Pass `--ops-target <url>` explicitly** (or set `FLAIR_OPS_TARGET`) on any command that
133
141
  touches the ops API: `init --target`, `agent add --target`, `federation token --target`.
134
142
 
135
- > **Gap — needs a Fabric account to verify.** This guide does not state the correct
136
- > ops-API URL for a managed `*.harperfabric.com` instance, or whether the ops API is
137
- > reachable remotely there at all. The derivation above is certain (read from source);
138
- > the right value to pass is not.
139
-
140
143
  Precedence: `--target` > `--url` > `FLAIR_TARGET` > `FLAIR_URL` > localhost. For ops:
141
144
  `--ops-target` > `FLAIR_OPS_TARGET` > derived > localhost.
142
145
 
@@ -250,8 +253,9 @@ and shells out to `lsof`. The command you'd reach for when something breaks is u
250
253
  here. Unavailable too: `start`, `stop`, `restart`, `snapshot`, `reembed`, `rem`, `bridge`.
251
254
 
252
255
  **Fabric's own cluster topology is invisible.** `fleet verify` sweeps *Flair's* federation
253
- peer table, not Harper's cluster nodes — `cluster_status` is harper-pro-only and absent
254
- from the OSS `harper` build. **`0 peers known` means "0 on file", never "0 exist."**
256
+ peer table, not Harper's cluster nodes. **`cluster_status` works on Fabric** — Fabric
257
+ always runs harper-pro (not the OSS harper build), so cluster_status is available over
258
+ the ops API. `0 peers known` means "0 on file", never "0 exist."
255
259
 
256
260
  **There is no disk or quota telemetry.** `flair status` reports usage for two directories:
257
261
  no free space, no total, no quota, no warning threshold, walk capped at six levels, no
@@ -263,6 +267,35 @@ with nothing saying so. The one indirect signal is a migration halting for space
263
267
  > wouldn't matter: Flair calls it only as a post-failure convergence oracle and discards
264
268
  > the `size` field. Component disk usage is invisible structurally.
265
269
 
270
+ ### The `mcp.enabled` operator step
271
+
272
+ MCP is **off by default**. The shipped component `config.yaml` contains
273
+ `@harperfast/oauth` → `mcp` → `enabled: false`. Until [flair#1152](https://github.com/tpsdev-ai/flair/issues/1152)
274
+ lands (interpolate from env — *ON HOLD*), you must flip this manually:
275
+
276
+ 1. In your deployed component's `config.yaml`, change:
277
+ ```yaml
278
+ '@harperfast/oauth':
279
+ mcp:
280
+ enabled: true # was: false
281
+ ```
282
+ 2. Re-deploy the component so Harper picks up the new value.
283
+ 3. **Verify the `/mcp` surface is actually serving** (the flag alone does not
284
+ guarantee it — a secret that is stored but never decrypted fails at self-verify):
285
+ ```bash
286
+ # Check /mcp is reachable and returning MCP protocol (not a loopback proxy or 404)
287
+ curl -sf https://\<cluster\>.\<org\>.harperfabric.com/mcp
288
+ # Should return MCP JSON-RPC content; if you get HTML redirect or 404 the flag
289
+ # is not effective
290
+ ```
291
+
292
+ **⚠ SECURITY CAVEAT — the upgrade-reverts trap.** Any package update or fleet component
293
+ update re-ships the literal `enabled: false` and silently darkens a live `/mcp` surface.
294
+ You must **re-flip to `true` after every upgrade** and re-deploy. An updated component
295
+ without this re-flip will appear healthy (`/Health` green) while its MCP tools are
296
+ dark to every connected client. If you rely on MCP, add the re-flip to your upgrade
297
+ runbook.
298
+
266
299
  ### Known hazard: unbounded npm cache
267
300
 
268
301
  **Open — [flair#886](https://github.com/tpsdev-ai/flair/issues/886).** Every deploy runs a
@@ -79,6 +79,35 @@ The staging file is written in every case, so a fallback never strands you mid-r
79
79
 
80
80
  `--secrets-mechanism <fabric-env-secrets|env-file>` remains an explicit override and skips the probe entirely.
81
81
 
82
+ ### The `mcp.enabled` operator step (Fabric)
83
+
84
+ MCP is **off by default**. The shipped component `config.yaml` contains
85
+ `@harperfast/oauth` → `mcp` → `enabled: false`. Until [flair#1152](https://github.com/tpsdev-ai/flair/issues/1152)
86
+ lands (interpolate from env — *ON HOLD*), you must flip this manually:
87
+
88
+ 1. In your deployed component's `config.yaml`, change:
89
+ ```yaml
90
+ '@harperfast/oauth':
91
+ mcp:
92
+ enabled: true # was: false
93
+ ```
94
+ 2. Re-deploy the component so Harper picks up the new value.
95
+ 3. **Verify the `/mcp` surface is actually serving** (the flag alone does not
96
+ guarantee it — a secret that is stored but never decrypted fails at self-verify):
97
+ ```bash
98
+ # Check /mcp is reachable and returning MCP protocol (not a loopback proxy or 404)
99
+ curl -sf https://<cluster>.<org>.harperfabric.com/mcp
100
+ # Should return MCP JSON-RPC content; if you get HTML redirect or 404 the flag
101
+ # is not effective
102
+ ```
103
+
104
+ **⚠ SECURITY CAVEAT — the upgrade-reverts trap.** Any package update or fleet component
105
+ update re-ships the literal `enabled: false` and silently darkens a live `/mcp` surface.
106
+ You must **re-flip to `true` after every upgrade** and re-deploy. An updated component
107
+ without this re-flip will appear healthy (`/Health` green) while its MCP tools are
108
+ dark to every connected client. If you rely on MCP, add the re-flip to your upgrade
109
+ runbook.
110
+
82
111
  ---
83
112
 
84
113
  ## Agent authentication
@@ -140,7 +169,7 @@ flair fleet verify --target https://<cluster>.<org>.harperfabric.com
140
169
 
141
170
  **`flair doctor`** takes no `--target` — it hardcodes localhost, reads a local PID file, and shells out to `lsof`. Unavailable too: `start`, `stop`, `restart`, `snapshot`, `reembed`, `rem`, `bridge`.
142
171
 
143
- **Fabric's own cluster topology is invisible.** `fleet verify` sweeps *Flair's* federation peer table, not Harper's cluster nodes. `cluster_status` is harper-pro-only. `0 peers known` means "0 on file", never "0 exist."
172
+ **Fabric's own cluster topology is invisible.** `fleet verify` sweeps *Flair's* federation peer table, not Harper's cluster nodes. **`cluster_status` works on Fabric** — Fabric always runs harper-pro (not the OSS harper build), so cluster_status is available over the ops API. `0 peers known` means "0 on file", never "0 exist."
144
173
 
145
174
  ---
146
175
 
@@ -109,7 +109,7 @@ Or wire it by hand — add a `SessionStart` hook to `~/.claude/settings.json`:
109
109
  "hooks": [
110
110
  {
111
111
  "type": "command",
112
- "command": "sh -c 'out=$(FLAIR_AGENT_ID=me npx -y @tpsdev-ai/flair-mcp flair-session-start 2>/dev/null) && printf %s \"$out\" || true'"
112
+ "command": "sh -c 'out=$(FLAIR_AGENT_ID=me npx -y -p @tpsdev-ai/flair-mcp flair-session-start 2>/dev/null) && printf %s \"$out\" || true'"
113
113
  }
114
114
  ]
115
115
  }
@@ -154,6 +154,8 @@ flair search --agent local "native addon loading in sandboxed runtimes"
154
154
 
155
155
  You searched for a concept, not the keywords. The line under each hit is its creation date, durability tier, and rank score.
156
156
 
157
+ > **When stdout is not a terminal** — piped to another command, captured in a script, or run in CI — the same `flair search` command emits a JSON array instead of the formatted prose above. Each hit is an object with `id`, `text`, `createdAt`, `durability`, and `_score` fields. Add `--explain` to include an `_explain` ranking breakdown on each hit. Use `flair search --json` to force JSON output even in a terminal, or `flair memory search` for the raw JSON form in all contexts.
158
+
157
159
  > The percentage is a **rank-fusion score, not a similarity**. It is normalized so the top result is always near 100%. Read it as ordering within these results, never as confidence that the match is good.
158
160
 
159
161
  Add `--explain` to see the ranking inputs per hit — the raw score, the composite score under `--scoring composite`, and the record's durability, age and usage count. When output is JSON (`--json`, or any time stdout is not a terminal) the same breakdown arrives as an `_explain` object on each hit, so scripts get it too. Use `--limit`, `--tag`, `--since 7d` to narrow the search. `flair memory search` runs the same query but always prints raw JSON — use it when piping to a script.
@@ -23,7 +23,8 @@ If it fails to start:
23
23
  lsof -i :19926
24
24
 
25
25
  # Check logs (macOS)
26
- cat ~/.flair/data/log/hdb.log | tail -50
26
+ cat ~/.flair/data/log/hdb.log | tail -50 # Harper ≤ 5.1 only (see below)
27
+ cat ~/.flair/data/log/system.log | tail -50 # Harper 5.2+ (live log)
27
28
 
28
29
  # Check logs (Linux)
29
30
  journalctl --user -u flair --since "10 minutes ago"
@@ -242,6 +243,14 @@ flair --help # all commands
242
243
  flair <command> -h # command-specific help
243
244
  ```
244
245
 
245
- Logs: `~/.flair/data/log/hdb.log`
246
+ **Log paths depend on Harper version:**
247
+
248
+ - **Harper ≤ 5.1:** `~/.flair/data/log/hdb.log`
249
+ - **Harper 5.2+:** `~/.flair/data/log/system.log`
250
+
251
+ > ⚠ **On Harper 5.2+, `hdb.log` freezes at the upgrade boundary.** The live log is
252
+ > `system.log`. The Harper ops `read_log` endpoint keeps serving the frozen `hdb.log`,
253
+ > so remote diagnosis reads days-stale entries that look current. Always check
254
+ > `system.log` after upgrading to 5.2+.
246
255
 
247
256
  File issues: [github.com/tpsdev-ai/flair/issues](https://github.com/tpsdev-ai/flair/issues)
package/docs/upgrade.md CHANGED
@@ -177,7 +177,8 @@ If you'd rather upgrade by hand instead of `flair upgrade`:
177
177
 
178
178
  ```bash
179
179
  npm install -g @tpsdev-ai/flair@latest
180
- npm install -g @tpsdev-ai/flair-mcp@latest # if installed
180
+ # flair-mcp is zero-install via npx — no global install needed.
181
+ # flair doctor --fix rewires the hook to the current npx form.
181
182
  flair restart
182
183
  ```
183
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.42.0",
3
+ "version": "0.44.1",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",