@tpsdev-ai/flair 0.46.0 → 0.47.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/build-info.json +6 -0
- package/dist/cli.js +563 -244
- package/dist/doctor-client.js +92 -11
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +12 -2
- package/dist/lib/scheduler-platform.js +153 -0
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +15 -9
- package/dist/resources/AttentionQuery.js +5 -3
- package/dist/resources/AutoPromoteCandidates.js +18 -12
- package/dist/resources/Federation.js +49 -5
- package/dist/resources/Memory.js +36 -2
- package/dist/resources/MemoryBootstrap.js +118 -7
- package/dist/resources/MemoryReflect.js +70 -5
- package/dist/resources/auto-promote-lib.js +46 -0
- package/dist/resources/build-info.js +50 -0
- package/dist/resources/entity-vocab.js +25 -1
- package/dist/resources/health.js +25 -5
- package/dist/resources/mcp-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +153 -0
- package/dist/src/rem/scheduler.js +15 -9
- package/docs/deepseek-harness.md +110 -0
- package/docs/entity-vocabulary.md +15 -0
- package/docs/integrations.md +1 -0
- package/docs/mcp-clients.md +4 -0
- package/docs/notes/mcp-oauth-model2.md +42 -0
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
package/dist/cli.js
CHANGED
|
@@ -6,10 +6,11 @@ import * as render from "./render.js";
|
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join, resolve, sep, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { spawn, execFileSync, spawnSync, execSync } from "node:child_process";
|
|
10
11
|
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
11
12
|
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
12
|
-
import { keystore } from "./keystore.js";
|
|
13
|
+
import { keystore, keyPath as keystoreKeyPath } from "./keystore.js";
|
|
13
14
|
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl, resolveDeployPublicUrl } from "./deploy.js";
|
|
14
15
|
import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, assertNoSecretKeysAdded, describePublicUrlFinding, planComponentEnv, readEnvValue, } from "./component-env.js";
|
|
15
16
|
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
@@ -23,11 +24,13 @@ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemi
|
|
|
23
24
|
import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
|
|
24
25
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
25
26
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
26
|
-
import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
|
|
27
|
+
import { readClientMcpBlock, effectiveFlairUrl, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
|
|
28
|
+
import { checkGlobalBinOnPath, cliBootPathWarning, resolveNpmGlobalPrefix, } from "./install/global-bin-path.js";
|
|
27
29
|
import { installHook, uninstallHook, hookStatus, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
28
30
|
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
29
31
|
import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
|
|
30
32
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
33
|
+
import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
|
|
31
34
|
import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
|
|
32
35
|
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
|
|
33
36
|
// Value-only static import so `--interval`'s advertised default cannot drift
|
|
@@ -2558,11 +2561,13 @@ export function upgradeStatusSuffix(name, status) {
|
|
|
2558
2561
|
}
|
|
2559
2562
|
if (status === "optional")
|
|
2560
2563
|
return " (install via: openclaw plugins install @tpsdev-ai/openclaw-flair)";
|
|
2561
|
-
// flair-mcp is refreshed by re-pinning its wiring
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
+
// flair-mcp is refreshed by re-pinning its wiring, never `npm install -g` —
|
|
2565
|
+
// a global bin does nothing for an `npx -y -p @tpsdev-ai/flair-mcp`
|
|
2566
|
+
// invocation (flair#1208). The re-pin is `flair upgrade`'s own job (the
|
|
2567
|
+
// #1135/#1167 pin refresh) — never advise `doctor --fix` for it
|
|
2568
|
+
// (flair#1324).
|
|
2564
2569
|
if (status === "outdated" && name === FLAIR_MCP_PACKAGE) {
|
|
2565
|
-
return " (npx-wired —
|
|
2570
|
+
return " (npx-wired — flair upgrade refreshes the pin)";
|
|
2566
2571
|
}
|
|
2567
2572
|
return "";
|
|
2568
2573
|
}
|
|
@@ -4009,6 +4014,13 @@ agent
|
|
|
4009
4014
|
: `✅ Agent '${id}' (${name}) registered`);
|
|
4010
4015
|
console.log(` Private key: ${privPath}`);
|
|
4011
4016
|
console.log(` Public key: ${pubKeyB64url}`);
|
|
4017
|
+
// flair#1280 — connector legibility at provisioning time: an OAuth /mcp
|
|
4018
|
+
// connector resolves its own token subject to an Agent via
|
|
4019
|
+
// Credential(kind:idp), NOT via this key, and the two identities are
|
|
4020
|
+
// DISTINCT unless linked. One line here saves the "my connector memory is
|
|
4021
|
+
// empty" discovery later.
|
|
4022
|
+
console.log(` Note: an OAuth /mcp connector maps its own IdP subject to an Agent (distinct from '${id}' by default).\n` +
|
|
4023
|
+
` To point a connector at '${id}': flair mcp enable --principal ${id} --idp-subject <your-idp-login>`);
|
|
4012
4024
|
});
|
|
4013
4025
|
agent
|
|
4014
4026
|
.command("list")
|
|
@@ -5965,7 +5977,14 @@ async function loadInstanceSecretKey(instanceId, opts) {
|
|
|
5965
5977
|
return nacl.sign.keyPair.fromSeed(new Uint8Array(seedFromDb)).secretKey;
|
|
5966
5978
|
}
|
|
5967
5979
|
}
|
|
5968
|
-
|
|
5980
|
+
// flair#1233: the old advice here — "Re-run 'flair federation status' to
|
|
5981
|
+
// regenerate" — was impossible: the server's create branch only fires when
|
|
5982
|
+
// NO Instance row exists, so a re-run can never regenerate a key for an
|
|
5983
|
+
// existing identity. Name the real remedy instead.
|
|
5984
|
+
throw new Error(`No usable private key for instance ${instanceId}. Expected keystore file: ${keystoreKeyPath(instanceId)} ` +
|
|
5985
|
+
`(no legacy _keySeed in the Instance table either). Restore that key file from a backup of ~/.flair/keys ` +
|
|
5986
|
+
`(and FLAIR_KEY_PASSPHRASE, if one was set when it was written), or re-key this instance: delete its ` +
|
|
5987
|
+
`Instance row and re-pair to mint a fresh identity.`);
|
|
5969
5988
|
}
|
|
5970
5989
|
/**
|
|
5971
5990
|
* Sign a request body and return a new body with the signature field added.
|
|
@@ -6040,165 +6059,48 @@ federation
|
|
|
6040
6059
|
const target = resolveTarget(opts);
|
|
6041
6060
|
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
6042
6061
|
const mode = render.resolveOutputMode(opts);
|
|
6062
|
+
// flair#1233: fetch instance and peers INDEPENDENTLY. One read failing
|
|
6063
|
+
// must never take down the whole render — the principle latestPeerContact
|
|
6064
|
+
// already documents for the driver verdict ("must never be the reason
|
|
6065
|
+
// `federation status` fails"), extended to the two primary reads. Print
|
|
6066
|
+
// what's available; mark the rest unverifiable.
|
|
6067
|
+
let instance = null;
|
|
6068
|
+
let instanceErr = null;
|
|
6043
6069
|
try {
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
for (const p of peers ?? []) {
|
|
6056
|
-
if (!p?.lastSyncAt)
|
|
6057
|
-
continue;
|
|
6058
|
-
const t = Date.parse(p.lastSyncAt);
|
|
6059
|
-
if (Number.isFinite(t) && (lastSyncAt === null || t > Date.parse(lastSyncAt))) {
|
|
6060
|
-
lastSyncAt = new Date(t).toISOString();
|
|
6061
|
-
}
|
|
6062
|
-
}
|
|
6063
|
-
assessment = assessDriver({
|
|
6064
|
-
installed: driver.installed,
|
|
6065
|
-
active: driver.active,
|
|
6066
|
-
intervalSeconds: driver.intervalSeconds,
|
|
6067
|
-
lastSyncAt,
|
|
6068
|
-
now: Date.now(),
|
|
6069
|
-
});
|
|
6070
|
-
}
|
|
6071
|
-
catch {
|
|
6072
|
-
// An unsupported platform (neither darwin nor linux) or an
|
|
6073
|
-
// unreadable unit must not take down `federation status` — the peer
|
|
6074
|
-
// table is still the primary output.
|
|
6075
|
-
driver = null;
|
|
6076
|
-
assessment = null;
|
|
6077
|
-
}
|
|
6078
|
-
}
|
|
6079
|
-
if (mode === "json") {
|
|
6080
|
-
console.log(render.asJSON({ instance, peers, driver, driverAssessment: assessment }));
|
|
6081
|
-
return;
|
|
6082
|
-
}
|
|
6083
|
-
const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
|
|
6084
|
-
console.log(render.wrap(render.c.bold, "Federation"));
|
|
6085
|
-
console.log(render.kv("Instance", `${instance.id} ${render.wrap(render.c.dim, `(${instance.role})`)}`));
|
|
6086
|
-
console.log(render.kv("Public key", render.wrap(render.c.dim, instance.publicKey)));
|
|
6087
|
-
console.log(render.kv("Status", render.wrap(statusColor, instance.status)));
|
|
6088
|
-
if (peers.length === 0) {
|
|
6089
|
-
console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
|
|
6090
|
-
return;
|
|
6091
|
-
}
|
|
6092
|
-
// Print the driver line BEFORE the per-peer table: "is anything running
|
|
6093
|
-
// sync at all" is the question that decides how to read everything
|
|
6094
|
-
// below it.
|
|
6095
|
-
if (assessment) {
|
|
6096
|
-
const icon = assessment.verdict === "driving" || assessment.verdict === "external-driver"
|
|
6097
|
-
? render.icons.ok
|
|
6098
|
-
: assessment.verdict === "unknown"
|
|
6099
|
-
? render.icons.info
|
|
6100
|
-
: render.icons.warn;
|
|
6101
|
-
const color = assessment.verdict === "driving" || assessment.verdict === "external-driver"
|
|
6102
|
-
? render.c.green
|
|
6103
|
-
: assessment.verdict === "unknown"
|
|
6104
|
-
? render.c.dim
|
|
6105
|
-
: render.c.yellow;
|
|
6106
|
-
console.log();
|
|
6107
|
-
console.log(`${icon} ${render.wrap(color, assessment.headline)}`);
|
|
6108
|
-
console.log(` ${render.wrap(render.c.dim, assessment.detail)}`);
|
|
6109
|
-
if (assessment.remedy)
|
|
6110
|
-
console.log(` ${render.wrap(render.c.cyan, assessment.remedy)}`);
|
|
6111
|
-
}
|
|
6112
|
-
const now = Date.now();
|
|
6113
|
-
const formatPeerAge = (iso, refNow, staleAfterMs) => {
|
|
6114
|
-
if (!iso)
|
|
6115
|
-
return render.wrap(render.c.red, "never");
|
|
6116
|
-
const t = Date.parse(iso);
|
|
6117
|
-
if (!Number.isFinite(t))
|
|
6118
|
-
return render.wrap(render.c.red, "never");
|
|
6119
|
-
const ageMs = refNow - t;
|
|
6120
|
-
const ageStr = ageMs < 60_000 ? "<1m ago"
|
|
6121
|
-
: ageMs < 3_600_000 ? `${Math.floor(ageMs / 60_000)}m ago`
|
|
6122
|
-
: ageMs < 86_400_000 ? `${Math.floor(ageMs / 3_600_000)}h ago`
|
|
6123
|
-
: `${Math.floor(ageMs / 86_400_000)}d ago`;
|
|
6124
|
-
const stale = ageMs > staleAfterMs;
|
|
6125
|
-
return render.wrap(stale ? render.c.yellow : render.c.dim, ageStr);
|
|
6126
|
-
};
|
|
6127
|
-
console.log();
|
|
6128
|
-
const cols = [
|
|
6129
|
-
{ label: "peer", key: "id" },
|
|
6130
|
-
{ label: "role", key: "role", format: (v) => String(v ?? "—") },
|
|
6131
|
-
{
|
|
6132
|
-
label: "status",
|
|
6133
|
-
key: "status",
|
|
6134
|
-
format: (v) => {
|
|
6135
|
-
const s = String(v ?? "—");
|
|
6136
|
-
const color = s === "paired" || s === "connected" || s === "active" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
|
|
6137
|
-
return render.wrap(color, s);
|
|
6138
|
-
},
|
|
6139
|
-
},
|
|
6140
|
-
{
|
|
6141
|
-
// Liveness: "did we hear from this peer recently?" Updates on every
|
|
6142
|
-
// contact, even when 100% of records were skipped. See flair#444.
|
|
6143
|
-
label: "last_sync",
|
|
6144
|
-
key: "lastSyncAt",
|
|
6145
|
-
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
6146
|
-
},
|
|
6147
|
-
{
|
|
6148
|
-
// Progress: "did data actually flow in?" Updates only when merged>0.
|
|
6149
|
-
// Diverging from last_sync means contact-yes but data-no — investigate.
|
|
6150
|
-
label: "last_merge",
|
|
6151
|
-
key: "lastMergeAt",
|
|
6152
|
-
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
6153
|
-
},
|
|
6154
|
-
{
|
|
6155
|
-
label: "relay",
|
|
6156
|
-
key: "relayOnly",
|
|
6157
|
-
format: (v) => (v ? render.wrap(render.c.cyan, "yes") : render.wrap(render.c.dim, "no")),
|
|
6158
|
-
},
|
|
6159
|
-
];
|
|
6160
|
-
console.log(render.table(cols, peers));
|
|
6161
|
-
// Stale warning is gated on lastMergeAt (real progress), not lastSyncAt.
|
|
6162
|
-
// A peer that "syncs" every 5min but hasn't merged a record in 24h is
|
|
6163
|
-
// exactly the failure mode we want surfaced.
|
|
6164
|
-
const haveStale = peers.some((p) => {
|
|
6165
|
-
const cursor = p.lastMergeAt ?? p.lastSyncAt;
|
|
6166
|
-
if (!cursor)
|
|
6167
|
-
return true;
|
|
6168
|
-
const t = Date.parse(cursor);
|
|
6169
|
-
return !Number.isFinite(t) || (now - t) > 86_400_000;
|
|
6170
|
-
});
|
|
6171
|
-
if (haveStale) {
|
|
6172
|
-
console.log();
|
|
6173
|
-
// The staleness warning used to fire identically whether sync was
|
|
6174
|
-
// running and the peer was unreachable, or nothing had run sync since
|
|
6175
|
-
// the day the spoke was paired (flair#922). Those need opposite
|
|
6176
|
-
// actions, so the remedy is now chosen by the driver verdict instead
|
|
6177
|
-
// of always pointing at SyncLog.
|
|
6178
|
-
const noDriver = assessment?.verdict === "no-driver" || assessment?.verdict === "driver-inactive";
|
|
6179
|
-
const remedy = noDriver
|
|
6180
|
-
? "Nothing is driving sync — see the driver line above. Run 'flair federation sync enable'."
|
|
6181
|
-
: "Check skippedReasons in SyncLog or run 'flair federation sync'.";
|
|
6182
|
-
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, remedy)}`);
|
|
6183
|
-
}
|
|
6184
|
-
const haveContactButNoMerge = peers.some((p) => {
|
|
6185
|
-
if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
|
|
6186
|
-
return false;
|
|
6187
|
-
if ((now - Date.parse(p.lastSyncAt)) > 3_600_000)
|
|
6188
|
-
return false; // only recent contact
|
|
6189
|
-
// Contact within the last hour, but no merge ever (or stale by >1h)
|
|
6190
|
-
if (!p.lastMergeAt)
|
|
6191
|
-
return true;
|
|
6192
|
-
const tm = Date.parse(p.lastMergeAt);
|
|
6193
|
-
return !Number.isFinite(tm) || (now - tm) > 3_600_000;
|
|
6194
|
-
});
|
|
6195
|
-
if (haveContactButNoMerge && !haveStale) {
|
|
6196
|
-
console.log();
|
|
6197
|
-
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Peer contact is fresh but no records merged in the last hour.")} ${render.wrap(render.c.dim, "Possible silent-skip scenario — check SyncLog.skippedReasons.")}`);
|
|
6198
|
-
}
|
|
6070
|
+
instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
|
|
6071
|
+
}
|
|
6072
|
+
catch (err) {
|
|
6073
|
+
instanceErr = err;
|
|
6074
|
+
}
|
|
6075
|
+
// peers: null = unverifiable (the read failed), [] = verified empty.
|
|
6076
|
+
let peers = null;
|
|
6077
|
+
let peersErr = null;
|
|
6078
|
+
try {
|
|
6079
|
+
const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
|
|
6080
|
+
peers = r.peers ?? [];
|
|
6199
6081
|
}
|
|
6200
6082
|
catch (err) {
|
|
6201
|
-
|
|
6083
|
+
peersErr = err;
|
|
6084
|
+
}
|
|
6085
|
+
// Auth-shaped failures stay FATAL even when the other read succeeded:
|
|
6086
|
+
// both endpoints sit behind the same allowAdmin gate, so a 401/403 is a
|
|
6087
|
+
// property of the session's credentials, not of one endpoint — and
|
|
6088
|
+
// degrading it to "unverifiable" would swallow the actionable remedy
|
|
6089
|
+
// (flair#634's UX, kept). Only non-auth failures degrade independently.
|
|
6090
|
+
const authShaped = (err) => {
|
|
6091
|
+
if (!err)
|
|
6092
|
+
return false;
|
|
6093
|
+
if (err.status === 401 || err.status === 403)
|
|
6094
|
+
return true;
|
|
6095
|
+
const m = String(err.message ?? err);
|
|
6096
|
+
return m.includes("missing_or_invalid_authorization") || m.includes("401");
|
|
6097
|
+
};
|
|
6098
|
+
// Both reads failed → nothing to render at all. Either way keep the
|
|
6099
|
+
// classic failure UX (auth remedy when it's an auth problem), exit
|
|
6100
|
+
// non-zero.
|
|
6101
|
+
if ((instanceErr && peersErr) || authShaped(instanceErr) || authShaped(peersErr)) {
|
|
6102
|
+
const primaryErr = instanceErr ?? peersErr;
|
|
6103
|
+
const msg = String(primaryErr.message ?? primaryErr);
|
|
6202
6104
|
if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
|
|
6203
6105
|
console.error(`${render.icons.error} federation status requires auth.`);
|
|
6204
6106
|
console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
|
|
@@ -6210,6 +6112,203 @@ federation
|
|
|
6210
6112
|
console.error(`${render.icons.error} ${msg}`);
|
|
6211
6113
|
process.exit(1);
|
|
6212
6114
|
}
|
|
6115
|
+
// Driver state (flair#922). Computed from the LOCAL service manager, so
|
|
6116
|
+
// it is only meaningful when the CLI is pointed at the local instance —
|
|
6117
|
+
// and only when the peer read succeeded: the verdict is derived from
|
|
6118
|
+
// peer contact, so with peers unverifiable it would be a confident claim
|
|
6119
|
+
// built on no data.
|
|
6120
|
+
let driver = null;
|
|
6121
|
+
let assessment = null;
|
|
6122
|
+
if (peers !== null && driverCheckAppliesTo(opts)) {
|
|
6123
|
+
try {
|
|
6124
|
+
const { schedulerStatus, assessDriver } = await import("./federation/scheduler.js");
|
|
6125
|
+
driver = schedulerStatus();
|
|
6126
|
+
let lastSyncAt = null;
|
|
6127
|
+
for (const p of peers) {
|
|
6128
|
+
if (!p?.lastSyncAt)
|
|
6129
|
+
continue;
|
|
6130
|
+
const t = Date.parse(p.lastSyncAt);
|
|
6131
|
+
if (Number.isFinite(t) && (lastSyncAt === null || t > Date.parse(lastSyncAt))) {
|
|
6132
|
+
lastSyncAt = new Date(t).toISOString();
|
|
6133
|
+
}
|
|
6134
|
+
}
|
|
6135
|
+
assessment = assessDriver({
|
|
6136
|
+
installed: driver.installed,
|
|
6137
|
+
active: driver.active,
|
|
6138
|
+
intervalSeconds: driver.intervalSeconds,
|
|
6139
|
+
lastSyncAt,
|
|
6140
|
+
now: Date.now(),
|
|
6141
|
+
});
|
|
6142
|
+
}
|
|
6143
|
+
catch {
|
|
6144
|
+
// An unsupported platform (neither darwin nor linux) or an
|
|
6145
|
+
// unreadable unit must not take down `federation status` — the peer
|
|
6146
|
+
// table is still the primary output.
|
|
6147
|
+
driver = null;
|
|
6148
|
+
assessment = null;
|
|
6149
|
+
}
|
|
6150
|
+
}
|
|
6151
|
+
if (mode === "json") {
|
|
6152
|
+
console.log(render.asJSON({
|
|
6153
|
+
instance,
|
|
6154
|
+
peers,
|
|
6155
|
+
driver,
|
|
6156
|
+
driverAssessment: assessment,
|
|
6157
|
+
// flair#1233: name what could not be read, so a partial result is
|
|
6158
|
+
// distinguishable from "verified absent" (instance/peers stay null
|
|
6159
|
+
// when their read failed).
|
|
6160
|
+
...(instanceErr || peersErr
|
|
6161
|
+
? {
|
|
6162
|
+
unverifiable: {
|
|
6163
|
+
...(instanceErr ? { instance: String(instanceErr.message ?? instanceErr) } : {}),
|
|
6164
|
+
...(peersErr ? { peers: String(peersErr.message ?? peersErr) } : {}),
|
|
6165
|
+
},
|
|
6166
|
+
}
|
|
6167
|
+
: {}),
|
|
6168
|
+
}));
|
|
6169
|
+
return;
|
|
6170
|
+
}
|
|
6171
|
+
console.log(render.wrap(render.c.bold, "Federation"));
|
|
6172
|
+
if (instance) {
|
|
6173
|
+
const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
|
|
6174
|
+
console.log(render.kv("Instance", `${instance.id} ${render.wrap(render.c.dim, `(${instance.role})`)}`));
|
|
6175
|
+
console.log(render.kv("Public key", render.wrap(render.c.dim, instance.publicKey)));
|
|
6176
|
+
console.log(render.kv("Status", render.wrap(statusColor, instance.status)));
|
|
6177
|
+
// flair#1233 degraded marker — actor + state + remedy. The server sets
|
|
6178
|
+
// signingKeyAvailable (runtime-only) on GET /FederationInstance; only
|
|
6179
|
+
// an explicit false fires this. Older servers omit the field, which
|
|
6180
|
+
// proves nothing either way, so no marker.
|
|
6181
|
+
if (instance.signingKeyAvailable === false) {
|
|
6182
|
+
console.log();
|
|
6183
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Signing key unavailable — the server could not store or read this instance's private key.")}`);
|
|
6184
|
+
console.log(` ${render.wrap(render.c.dim, "Reads (this status) still work; pair/sync signing on that instance will fail until the keystore is fixed.")}`);
|
|
6185
|
+
console.log(` ${render.wrap(render.c.cyan, "Fix on the server: make $HOME/.flair/keys (under the Harper process's HOME) a directory writable by the Harper process, mode 0700. If the key was never stored, re-key: delete the Instance row and re-pair.")}`);
|
|
6186
|
+
}
|
|
6187
|
+
}
|
|
6188
|
+
else {
|
|
6189
|
+
// Instance read failed but peers succeeded — render what we have.
|
|
6190
|
+
console.log(render.kv("Instance", `${render.icons.warn} unverifiable — ${render.wrap(render.c.dim, String(instanceErr?.message ?? instanceErr))}`));
|
|
6191
|
+
}
|
|
6192
|
+
if (peers === null) {
|
|
6193
|
+
// Peer read failed but the instance rendered — say so explicitly
|
|
6194
|
+
// instead of aborting: "unverifiable" is a different claim from "no
|
|
6195
|
+
// peers", and conflating them is how hub state became unobservable.
|
|
6196
|
+
console.log();
|
|
6197
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Peers unverifiable — the peer read failed. This says nothing about whether peers exist or sync runs.")}`);
|
|
6198
|
+
console.log(` ${render.wrap(render.c.dim, String(peersErr?.message ?? peersErr))}`);
|
|
6199
|
+
return;
|
|
6200
|
+
}
|
|
6201
|
+
if (peers.length === 0) {
|
|
6202
|
+
console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
|
|
6203
|
+
return;
|
|
6204
|
+
}
|
|
6205
|
+
// Print the driver line BEFORE the per-peer table: "is anything running
|
|
6206
|
+
// sync at all" is the question that decides how to read everything
|
|
6207
|
+
// below it.
|
|
6208
|
+
if (assessment) {
|
|
6209
|
+
const icon = assessment.verdict === "driving" || assessment.verdict === "external-driver"
|
|
6210
|
+
? render.icons.ok
|
|
6211
|
+
: assessment.verdict === "unknown"
|
|
6212
|
+
? render.icons.info
|
|
6213
|
+
: render.icons.warn;
|
|
6214
|
+
const color = assessment.verdict === "driving" || assessment.verdict === "external-driver"
|
|
6215
|
+
? render.c.green
|
|
6216
|
+
: assessment.verdict === "unknown"
|
|
6217
|
+
? render.c.dim
|
|
6218
|
+
: render.c.yellow;
|
|
6219
|
+
console.log();
|
|
6220
|
+
console.log(`${icon} ${render.wrap(color, assessment.headline)}`);
|
|
6221
|
+
console.log(` ${render.wrap(render.c.dim, assessment.detail)}`);
|
|
6222
|
+
if (assessment.remedy)
|
|
6223
|
+
console.log(` ${render.wrap(render.c.cyan, assessment.remedy)}`);
|
|
6224
|
+
}
|
|
6225
|
+
const now = Date.now();
|
|
6226
|
+
const formatPeerAge = (iso, refNow, staleAfterMs) => {
|
|
6227
|
+
if (!iso)
|
|
6228
|
+
return render.wrap(render.c.red, "never");
|
|
6229
|
+
const t = Date.parse(iso);
|
|
6230
|
+
if (!Number.isFinite(t))
|
|
6231
|
+
return render.wrap(render.c.red, "never");
|
|
6232
|
+
const ageMs = refNow - t;
|
|
6233
|
+
const ageStr = ageMs < 60_000 ? "<1m ago"
|
|
6234
|
+
: ageMs < 3_600_000 ? `${Math.floor(ageMs / 60_000)}m ago`
|
|
6235
|
+
: ageMs < 86_400_000 ? `${Math.floor(ageMs / 3_600_000)}h ago`
|
|
6236
|
+
: `${Math.floor(ageMs / 86_400_000)}d ago`;
|
|
6237
|
+
const stale = ageMs > staleAfterMs;
|
|
6238
|
+
return render.wrap(stale ? render.c.yellow : render.c.dim, ageStr);
|
|
6239
|
+
};
|
|
6240
|
+
console.log();
|
|
6241
|
+
const cols = [
|
|
6242
|
+
{ label: "peer", key: "id" },
|
|
6243
|
+
{ label: "role", key: "role", format: (v) => String(v ?? "—") },
|
|
6244
|
+
{
|
|
6245
|
+
label: "status",
|
|
6246
|
+
key: "status",
|
|
6247
|
+
format: (v) => {
|
|
6248
|
+
const s = String(v ?? "—");
|
|
6249
|
+
const color = s === "paired" || s === "connected" || s === "active" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
|
|
6250
|
+
return render.wrap(color, s);
|
|
6251
|
+
},
|
|
6252
|
+
},
|
|
6253
|
+
{
|
|
6254
|
+
// Liveness: "did we hear from this peer recently?" Updates on every
|
|
6255
|
+
// contact, even when 100% of records were skipped. See flair#444.
|
|
6256
|
+
label: "last_sync",
|
|
6257
|
+
key: "lastSyncAt",
|
|
6258
|
+
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
6259
|
+
},
|
|
6260
|
+
{
|
|
6261
|
+
// Progress: "did data actually flow in?" Updates only when merged>0.
|
|
6262
|
+
// Diverging from last_sync means contact-yes but data-no — investigate.
|
|
6263
|
+
label: "last_merge",
|
|
6264
|
+
key: "lastMergeAt",
|
|
6265
|
+
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
6266
|
+
},
|
|
6267
|
+
{
|
|
6268
|
+
label: "relay",
|
|
6269
|
+
key: "relayOnly",
|
|
6270
|
+
format: (v) => (v ? render.wrap(render.c.cyan, "yes") : render.wrap(render.c.dim, "no")),
|
|
6271
|
+
},
|
|
6272
|
+
];
|
|
6273
|
+
console.log(render.table(cols, peers));
|
|
6274
|
+
// Stale warning is gated on lastMergeAt (real progress), not lastSyncAt.
|
|
6275
|
+
// A peer that "syncs" every 5min but hasn't merged a record in 24h is
|
|
6276
|
+
// exactly the failure mode we want surfaced.
|
|
6277
|
+
const haveStale = peers.some((p) => {
|
|
6278
|
+
const cursor = p.lastMergeAt ?? p.lastSyncAt;
|
|
6279
|
+
if (!cursor)
|
|
6280
|
+
return true;
|
|
6281
|
+
const t = Date.parse(cursor);
|
|
6282
|
+
return !Number.isFinite(t) || (now - t) > 86_400_000;
|
|
6283
|
+
});
|
|
6284
|
+
if (haveStale) {
|
|
6285
|
+
console.log();
|
|
6286
|
+
// The staleness warning used to fire identically whether sync was
|
|
6287
|
+
// running and the peer was unreachable, or nothing had run sync since
|
|
6288
|
+
// the day the spoke was paired (flair#922). Those need opposite
|
|
6289
|
+
// actions, so the remedy is now chosen by the driver verdict instead
|
|
6290
|
+
// of always pointing at SyncLog.
|
|
6291
|
+
const noDriver = assessment?.verdict === "no-driver" || assessment?.verdict === "driver-inactive";
|
|
6292
|
+
const remedy = noDriver
|
|
6293
|
+
? "Nothing is driving sync — see the driver line above. Run 'flair federation sync enable'."
|
|
6294
|
+
: "Check skippedReasons in SyncLog or run 'flair federation sync'.";
|
|
6295
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, remedy)}`);
|
|
6296
|
+
}
|
|
6297
|
+
const haveContactButNoMerge = peers.some((p) => {
|
|
6298
|
+
if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
|
|
6299
|
+
return false;
|
|
6300
|
+
if ((now - Date.parse(p.lastSyncAt)) > 3_600_000)
|
|
6301
|
+
return false; // only recent contact
|
|
6302
|
+
// Contact within the last hour, but no merge ever (or stale by >1h)
|
|
6303
|
+
if (!p.lastMergeAt)
|
|
6304
|
+
return true;
|
|
6305
|
+
const tm = Date.parse(p.lastMergeAt);
|
|
6306
|
+
return !Number.isFinite(tm) || (now - tm) > 3_600_000;
|
|
6307
|
+
});
|
|
6308
|
+
if (haveContactButNoMerge && !haveStale) {
|
|
6309
|
+
console.log();
|
|
6310
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Peer contact is fresh but no records merged in the last hour.")} ${render.wrap(render.c.dim, "Possible silent-skip scenario — check SyncLog.skippedReasons.")}`);
|
|
6311
|
+
}
|
|
6213
6312
|
});
|
|
6214
6313
|
// `flair federation reachability` — probe local instance + all paired peers.
|
|
6215
6314
|
// Productizes flair#695: a single command that tells
|
|
@@ -7819,6 +7918,43 @@ export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
|
|
|
7819
7918
|
const scopeTag = [...adkTags][0];
|
|
7820
7919
|
return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
|
|
7821
7920
|
}
|
|
7921
|
+
// ─── Promoted-row visibility (flair#1257 slice 3 — default-private-unless) ────
|
|
7922
|
+
// Continuity-journal scope tag prefix. Canonical string duplicated in
|
|
7923
|
+
// resources/memory-reflect-lib.ts / resources/auto-promote-lib.ts and
|
|
7924
|
+
// packages/flair-mcp/src/continuity.ts — this file sits on the CLI side of the
|
|
7925
|
+
// npm-packaging boundary (see this file's header) and cannot import them; kept
|
|
7926
|
+
// in sync by the shared canonical string, same discipline as
|
|
7927
|
+
// MACHINE_REVIEWER_* below.
|
|
7928
|
+
export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
|
|
7929
|
+
/**
|
|
7930
|
+
* Decide a promoted Memory row's visibility for the HUMAN `rem promote` path
|
|
7931
|
+
* (flair#1257 slice 3). Mirror of resources/auto-promote-lib.ts
|
|
7932
|
+
* decidePromotedVisibility (the server-side auto-promote half) — Sherlock's
|
|
7933
|
+
* default-private-unless ruling covers BOTH promotion paths: the sources of a
|
|
7934
|
+
* continuity candidate are the most sensitive tier (ephemeral+private journal
|
|
7935
|
+
* rows), so leaving visibility unset here would let Memory's durability-keyed
|
|
7936
|
+
* default widen it to shared ("persistent" defaults shared) — a silent
|
|
7937
|
+
* visibility escalation. "shared" only when the candidate is continuity-scoped
|
|
7938
|
+
* AND carries the distiller's affirmative ruling WITH its recorded
|
|
7939
|
+
* team-relevance justification; every other case — including every
|
|
7940
|
+
* uncertainty — is "private".
|
|
7941
|
+
*
|
|
7942
|
+
* Returns undefined for NON-continuity candidates: their visibility behavior
|
|
7943
|
+
* (durability-keyed default) is byte-for-byte the pre-slice-3 contract and is
|
|
7944
|
+
* deliberately not changed here.
|
|
7945
|
+
*/
|
|
7946
|
+
export function derivePromotedVisibility(candidate) {
|
|
7947
|
+
const scopeTag = candidate.scopeTag;
|
|
7948
|
+
const isContinuity = typeof scopeTag === "string" &&
|
|
7949
|
+
scopeTag.length > CONTINUITY_SCOPE_TAG_PREFIX.length &&
|
|
7950
|
+
scopeTag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
|
|
7951
|
+
if (!isContinuity)
|
|
7952
|
+
return undefined;
|
|
7953
|
+
if (candidate.visibilityRuling !== "shared")
|
|
7954
|
+
return "private";
|
|
7955
|
+
const rationale = typeof candidate.visibilityRationale === "string" ? candidate.visibilityRationale.trim() : "";
|
|
7956
|
+
return rationale.length > 0 ? "shared" : "private";
|
|
7957
|
+
}
|
|
7822
7958
|
// ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
|
|
7823
7959
|
// A promotion records a reviewerId that feeds audit/attribution
|
|
7824
7960
|
// (schemas/memory.graphql:209). An automated (machine-driven) promotion path
|
|
@@ -7935,11 +8071,19 @@ rem
|
|
|
7935
8071
|
// Write the resulting Soul or Memory entry
|
|
7936
8072
|
if (opts.to === "memory") {
|
|
7937
8073
|
const memId = `${candidate.agentId}-promoted-${Date.now()}`;
|
|
8074
|
+
// flair#1257 slice 3: for a CONTINUITY-scoped candidate, visibility is
|
|
8075
|
+
// decided default-private-unless (derivePromotedVisibility) — the
|
|
8076
|
+
// sources are ephemeral+private journal rows, and an unset visibility
|
|
8077
|
+
// would silently widen to shared via the persistent durability
|
|
8078
|
+
// default. Non-continuity candidates return undefined here and keep
|
|
8079
|
+
// the pre-slice-3 durability-keyed default, unchanged.
|
|
8080
|
+
const promotedVisibility = derivePromotedVisibility(candidate);
|
|
7938
8081
|
const memWrite = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, {
|
|
7939
8082
|
id: memId,
|
|
7940
8083
|
agentId: candidate.agentId,
|
|
7941
8084
|
content: candidate.claim,
|
|
7942
8085
|
durability: "persistent",
|
|
8086
|
+
...(promotedVisibility ? { visibility: promotedVisibility } : {}),
|
|
7943
8087
|
tags: promotedTags,
|
|
7944
8088
|
derivedFrom: candidate.sourceMemoryIds ?? [],
|
|
7945
8089
|
promotionStatus: "approved",
|
|
@@ -10078,11 +10222,69 @@ program
|
|
|
10078
10222
|
console.log("\n✅ Everything is up to date.");
|
|
10079
10223
|
return;
|
|
10080
10224
|
}
|
|
10081
|
-
//
|
|
10082
|
-
//
|
|
10083
|
-
//
|
|
10084
|
-
//
|
|
10085
|
-
//
|
|
10225
|
+
// ONE pin-refresh implementation, two callers (flair#1324): the post-
|
|
10226
|
+
// install refresh below (#1135/#1167), and the stale-pin-only path — when
|
|
10227
|
+
// flair-mcp's wired pin is behind latest but no package needs installing,
|
|
10228
|
+
// `flair upgrade` refreshes the pin itself instead of advising a
|
|
10229
|
+
// `doctor --fix` round-trip. Only refreshes clients that are ALREADY
|
|
10230
|
+
// wired — never wires new ones. Best-effort: failures warn but never fail
|
|
10231
|
+
// the upgrade.
|
|
10232
|
+
async function refreshWiredMcpClientPins(targetPort) {
|
|
10233
|
+
const agentId = resolveAgentIdOrEnv({}) ?? (() => {
|
|
10234
|
+
try {
|
|
10235
|
+
const kd = defaultKeysDir();
|
|
10236
|
+
const keyFiles = readdirSync(kd).filter((f) => f.endsWith(".key"));
|
|
10237
|
+
// Node-scoped federation keys aren't agents (flair#1193) — never
|
|
10238
|
+
// pin-refresh a connector as one.
|
|
10239
|
+
const agentKeyFile = keyFiles.find((f) => !isNodeKeyId(f.replace(/\.key$/, ""), kd));
|
|
10240
|
+
return agentKeyFile ? agentKeyFile.replace(/\.key$/, "") : null;
|
|
10241
|
+
}
|
|
10242
|
+
catch {
|
|
10243
|
+
return null;
|
|
10244
|
+
}
|
|
10245
|
+
})();
|
|
10246
|
+
if (!agentId) {
|
|
10247
|
+
console.log("\n (no agent id known — skip MCP client pin refresh; run `flair init` to refresh manually)");
|
|
10248
|
+
return;
|
|
10249
|
+
}
|
|
10250
|
+
const httpUrl = `http://127.0.0.1:${targetPort}`;
|
|
10251
|
+
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
10252
|
+
const detected = detectClients().filter(c => c.detected);
|
|
10253
|
+
if (detected.length === 0)
|
|
10254
|
+
return;
|
|
10255
|
+
console.log("\n Refreshing MCP client pins...");
|
|
10256
|
+
for (const client of detected) {
|
|
10257
|
+
const configPath = clientConfigPath(client.id);
|
|
10258
|
+
if (!existsSync(configPath))
|
|
10259
|
+
continue;
|
|
10260
|
+
// Only refresh clients that are already wired — don't wire new ones.
|
|
10261
|
+
let hasFlair = false;
|
|
10262
|
+
try {
|
|
10263
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
10264
|
+
if (client.id === "codex") {
|
|
10265
|
+
hasFlair = codexConfigHasFlairSection(raw);
|
|
10266
|
+
}
|
|
10267
|
+
else {
|
|
10268
|
+
const cfg = JSON.parse(raw);
|
|
10269
|
+
hasFlair = !!cfg.mcpServers?.flair;
|
|
10270
|
+
}
|
|
10271
|
+
}
|
|
10272
|
+
catch { /* unreadable/malformed — skip */ }
|
|
10273
|
+
if (!hasFlair)
|
|
10274
|
+
continue;
|
|
10275
|
+
const env = { ...mcpEnv, FLAIR_CLIENT: client.id };
|
|
10276
|
+
const result = client.wire(env);
|
|
10277
|
+
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
10278
|
+
}
|
|
10279
|
+
}
|
|
10280
|
+
// Nothing to install via npm/openclaw. What is left is advisory (packages
|
|
10281
|
+
// not detected) and/or a flair-mcp whose wired pin is behind latest. The
|
|
10282
|
+
// stale pin is `flair upgrade`'s OWN job (flair#1324): refresh it right
|
|
10283
|
+
// here rather than bouncing the user to `flair doctor --fix` — advice
|
|
10284
|
+
// that was both roundabout and, until #1324, routed every upgrading user
|
|
10285
|
+
// through doctor's consent hazard. Under --check, only say what a real
|
|
10286
|
+
// run will do. `npm install -g` remains wrong for flair-mcp either way
|
|
10287
|
+
// (#1168/#1208).
|
|
10086
10288
|
if (totalUpgrades === 0) {
|
|
10087
10289
|
if (missing.length > 0) {
|
|
10088
10290
|
const npmMissing = missing.filter((f) => f.name !== FLAIR_MCP_PACKAGE);
|
|
@@ -10097,7 +10299,12 @@ program
|
|
|
10097
10299
|
}
|
|
10098
10300
|
if (flairMcpOutdated) {
|
|
10099
10301
|
console.log(`\n⬆️ flair-mcp is wired via npx (pinned ${flairMcpOutdated.installed} → latest ${flairMcpOutdated.latest}).`);
|
|
10100
|
-
|
|
10302
|
+
if (checkOnly) {
|
|
10303
|
+
console.log(" Run: flair upgrade (refreshes the pin)");
|
|
10304
|
+
}
|
|
10305
|
+
else {
|
|
10306
|
+
await refreshWiredMcpClientPins(resolveHttpPort({}));
|
|
10307
|
+
}
|
|
10101
10308
|
}
|
|
10102
10309
|
return;
|
|
10103
10310
|
}
|
|
@@ -10318,54 +10525,7 @@ program
|
|
|
10318
10525
|
// version. Runs BEFORE the restart so --no-restart and --no-verify paths
|
|
10319
10526
|
// also get the refresh (flair#1167). Best-effort: failures warn but never
|
|
10320
10527
|
// fail the upgrade.
|
|
10321
|
-
await (
|
|
10322
|
-
const agentId = resolveAgentIdOrEnv({}) ?? (() => {
|
|
10323
|
-
try {
|
|
10324
|
-
const kd = defaultKeysDir();
|
|
10325
|
-
const keyFiles = readdirSync(kd).filter((f) => f.endsWith(".key"));
|
|
10326
|
-
// Node-scoped federation keys aren't agents (flair#1193) — never
|
|
10327
|
-
// pin-refresh a connector as one.
|
|
10328
|
-
const agentKeyFile = keyFiles.find((f) => !isNodeKeyId(f.replace(/\.key$/, ""), kd));
|
|
10329
|
-
return agentKeyFile ? agentKeyFile.replace(/\.key$/, "") : null;
|
|
10330
|
-
}
|
|
10331
|
-
catch {
|
|
10332
|
-
return null;
|
|
10333
|
-
}
|
|
10334
|
-
})();
|
|
10335
|
-
if (!agentId) {
|
|
10336
|
-
console.log("\n (no agent id known — skip MCP client pin refresh; run `flair init` to refresh manually)");
|
|
10337
|
-
return;
|
|
10338
|
-
}
|
|
10339
|
-
const httpUrl = `http://127.0.0.1:${upgradePort}`;
|
|
10340
|
-
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
10341
|
-
const detected = detectClients().filter(c => c.detected);
|
|
10342
|
-
if (detected.length === 0)
|
|
10343
|
-
return;
|
|
10344
|
-
console.log("\n Refreshing MCP client pins...");
|
|
10345
|
-
for (const client of detected) {
|
|
10346
|
-
const configPath = clientConfigPath(client.id);
|
|
10347
|
-
if (!existsSync(configPath))
|
|
10348
|
-
continue;
|
|
10349
|
-
// Only refresh clients that are already wired — don't wire new ones.
|
|
10350
|
-
let hasFlair = false;
|
|
10351
|
-
try {
|
|
10352
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
10353
|
-
if (client.id === "codex") {
|
|
10354
|
-
hasFlair = codexConfigHasFlairSection(raw);
|
|
10355
|
-
}
|
|
10356
|
-
else {
|
|
10357
|
-
const cfg = JSON.parse(raw);
|
|
10358
|
-
hasFlair = !!cfg.mcpServers?.flair;
|
|
10359
|
-
}
|
|
10360
|
-
}
|
|
10361
|
-
catch { /* unreadable/malformed — skip */ }
|
|
10362
|
-
if (!hasFlair)
|
|
10363
|
-
continue;
|
|
10364
|
-
const env = { ...mcpEnv, FLAIR_CLIENT: client.id };
|
|
10365
|
-
const result = client.wire(env);
|
|
10366
|
-
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
10367
|
-
}
|
|
10368
|
-
})();
|
|
10528
|
+
await refreshWiredMcpClientPins(upgradePort);
|
|
10369
10529
|
// ── Restart + verify + rollback (flair#635) ─────────────────────────────
|
|
10370
10530
|
// Decision (2026-07-08): restart is now the default post-upgrade step —
|
|
10371
10531
|
// installing new code without restarting leaves the OLD process serving
|
|
@@ -12175,6 +12335,33 @@ program
|
|
|
12175
12335
|
`Commands run through the CLI; the instance serves the data.`)}`);
|
|
12176
12336
|
}
|
|
12177
12337
|
}
|
|
12338
|
+
// 0.5 npm global bin dir on PATH (flair#1134) — a user-prefix
|
|
12339
|
+
// `npm i -g` succeeds and then `flair` is command-not-found because
|
|
12340
|
+
// <prefix>/bin never made it into PATH. postinstall warns at install
|
|
12341
|
+
// time, but lifecycle scripts are suppressed on several real paths
|
|
12342
|
+
// (--ignore-scripts, bun without trustedDependencies, tar-swap
|
|
12343
|
+
// deploys), so doctor re-runs the same check — cheap, local, and
|
|
12344
|
+
// independent of Harper being up. When npm itself is absent or slow
|
|
12345
|
+
// the check SKIPS silently: flair may be installed by other means,
|
|
12346
|
+
// and "npm missing" has no actionable fix this check could print.
|
|
12347
|
+
const npmGlobalPrefix = await resolveNpmGlobalPrefix();
|
|
12348
|
+
if (npmGlobalPrefix) {
|
|
12349
|
+
const binCheck = checkGlobalBinOnPath({
|
|
12350
|
+
prefix: npmGlobalPrefix,
|
|
12351
|
+
pathEnv: process.env.PATH,
|
|
12352
|
+
shell: process.env.SHELL,
|
|
12353
|
+
});
|
|
12354
|
+
if ("message" in binCheck) {
|
|
12355
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `npm global bin dir ${binCheck.binDir} is NOT on PATH — global npm installs (flair included) won't be found by name`)}`);
|
|
12356
|
+
for (const line of binCheck.message.split("\n")) {
|
|
12357
|
+
console.log(` ${render.wrap(render.c.dim, line)}`);
|
|
12358
|
+
}
|
|
12359
|
+
issues++;
|
|
12360
|
+
}
|
|
12361
|
+
else {
|
|
12362
|
+
console.log(` ${render.icons.ok} npm global bin dir ${render.wrap(render.c.dim, binCheck.binDir)} is on PATH`);
|
|
12363
|
+
}
|
|
12364
|
+
}
|
|
12178
12365
|
// Helper: try to reach Harper on a given port.
|
|
12179
12366
|
// Must return true ONLY when Harper's /Health endpoint returns 200 OK.
|
|
12180
12367
|
// A generic HTTP status > 0 (flair#862) would accept 404 from a Node
|
|
@@ -12751,13 +12938,22 @@ program
|
|
|
12751
12938
|
continue;
|
|
12752
12939
|
}
|
|
12753
12940
|
console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
|
|
12754
|
-
|
|
12941
|
+
// flair#1287: a block with FLAIR_AGENT_ID but no FLAIR_URL is a
|
|
12942
|
+
// WORKING setup — flair-client falls back to its built-in default —
|
|
12943
|
+
// and must never be reported as unconfigured. Say which URL applies
|
|
12944
|
+
// and keep verifying against it, exactly as for an explicit one.
|
|
12945
|
+
const eff = effectiveFlairUrl(block);
|
|
12946
|
+
const urlLabel = eff.defaulted ? `${eff.url} (client default)` : eff.url;
|
|
12947
|
+
if (eff.defaulted) {
|
|
12948
|
+
console.log(` ${render.icons.info} FLAIR_URL not set — flair-mcp defaults to ${render.wrap(render.c.dim, eff.url)}`);
|
|
12949
|
+
}
|
|
12950
|
+
const reachable = await probeFlairReachable(eff.url);
|
|
12755
12951
|
if (!reachable) {
|
|
12756
|
-
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim,
|
|
12952
|
+
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} not reachable — cannot verify agent registration`);
|
|
12757
12953
|
continue;
|
|
12758
12954
|
}
|
|
12759
|
-
console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim,
|
|
12760
|
-
const reg = await checkAgentRegistered(
|
|
12955
|
+
console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} reachable`);
|
|
12956
|
+
const reg = await checkAgentRegistered(eff.url, block.agentId, defaultKeysDir());
|
|
12761
12957
|
if (reg.state === "registered") {
|
|
12762
12958
|
console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
|
|
12763
12959
|
}
|
|
@@ -12916,35 +13112,20 @@ program
|
|
|
12916
13112
|
// the SessionStart check above: installed / absent / stale-form).
|
|
12917
13113
|
// Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
|
|
12918
13114
|
// opt-in — so "absent" renders as informational "not enabled": NEVER
|
|
12919
|
-
// a pass (an unrun check must not look green)
|
|
12920
|
-
// issue
|
|
12921
|
-
//
|
|
12922
|
-
//
|
|
12923
|
-
//
|
|
13115
|
+
// a pass (an unrun check must not look green), never counted as an
|
|
13116
|
+
// issue, and NEVER wired by --fix (flair#1324: doctor's fixable set
|
|
13117
|
+
// is broken state; initiating an opt-in the user hasn't made is not a
|
|
13118
|
+
// fix — a y/N prompt auto-answers yes in every non-TTY run, so it was
|
|
13119
|
+
// no consent gate at all; enablement is `flair hook install
|
|
13120
|
+
// --continuity` only). A partial or stale pair IS evidence of a prior
|
|
13121
|
+
// opt-in, so repairing it to the complete current form remains a
|
|
13122
|
+
// legitimate --fix.
|
|
12924
13123
|
const continuity = checkContinuityCaptureHooks(homedir());
|
|
12925
13124
|
if (continuity.state === "installed") {
|
|
12926
13125
|
console.log(` ${render.icons.ok} Continuity capture hooks: PostToolUse + Stop wired in ${render.wrap(render.c.dim, continuity.path)}`);
|
|
12927
13126
|
}
|
|
12928
13127
|
else if (continuity.state === "absent") {
|
|
12929
13128
|
console.log(` ${render.icons.info} Continuity capture hooks: not enabled ${render.wrap(render.c.dim, "(opt-in — auto-journal working state into the ephemeral memory tier; enable: flair hook install --continuity)")}`);
|
|
12930
|
-
if (autoFix) {
|
|
12931
|
-
if (dryRun) {
|
|
12932
|
-
console.log(` ${render.wrap(render.c.dim, "Would wire the continuity capture hooks (PostToolUse + Stop) in")} ${continuity.path}`);
|
|
12933
|
-
}
|
|
12934
|
-
else {
|
|
12935
|
-
const proceed = await confirmFix(` Enable continuity capture (PostToolUse + Stop hooks in ${continuity.path})? [y/N] `);
|
|
12936
|
-
if (!proceed) {
|
|
12937
|
-
console.log(` Skipped.`);
|
|
12938
|
-
}
|
|
12939
|
-
else {
|
|
12940
|
-
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
12941
|
-
const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId);
|
|
12942
|
-
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
12943
|
-
if (fixRes.ok && fixRes.changed)
|
|
12944
|
-
fixed++;
|
|
12945
|
-
}
|
|
12946
|
-
}
|
|
12947
|
-
}
|
|
12948
13129
|
}
|
|
12949
13130
|
else {
|
|
12950
13131
|
const continuityDetail = continuity.state === "partial"
|
|
@@ -13224,6 +13405,80 @@ program
|
|
|
13224
13405
|
}
|
|
13225
13406
|
}
|
|
13226
13407
|
}
|
|
13408
|
+
// 10. Scheduled drivers (flair#1278) — launchd/systemd liveness for the
|
|
13409
|
+
// background schedulers (federation sync, REM nightly), read from the
|
|
13410
|
+
// LOCAL service manager (no Harper dependency, so no harperResponding
|
|
13411
|
+
// gate). Neither #1231 fleet incident (launchd spawn error 209 from a
|
|
13412
|
+
// missing log dir, exit 126 from a stripped exec bit) was visible in
|
|
13413
|
+
// doctor: driver health only surfaced in `flair federation sync status`
|
|
13414
|
+
// / `flair rem nightly status` — commands an operator has to think to
|
|
13415
|
+
// run, while doctor is the tool they actually run when something feels
|
|
13416
|
+
// off. Reuses each scheduler's own status read (installed + genuinely
|
|
13417
|
+
// loaded, flair#850) plus the #1282 last-exit plumbing
|
|
13418
|
+
// (queryLastExitStatus); the verdict is describeScheduledDriverFinding
|
|
13419
|
+
// (src/lib/scheduler-platform.ts) — pure decision logic, unit-tested
|
|
13420
|
+
// without spawning launchctl/systemctl. Not-enabled renders as
|
|
13421
|
+
// informational: an unenabled scheduler is a choice — never the pass
|
|
13422
|
+
// marker, never the fail marker, never an issue.
|
|
13423
|
+
console.log(`\n ${render.wrap(render.c.bold, "Scheduled drivers")}`);
|
|
13424
|
+
try {
|
|
13425
|
+
const { queryLastExitStatus, describeScheduledDriverFinding } = await import("./lib/scheduler-platform.js");
|
|
13426
|
+
const fedSched = await import("./federation/scheduler.js");
|
|
13427
|
+
const remSched = await import("./rem/scheduler.js");
|
|
13428
|
+
const guiDomain = `gui/${process.getuid?.() ?? ""}`;
|
|
13429
|
+
const drivers = [
|
|
13430
|
+
{
|
|
13431
|
+
status: fedSched.schedulerStatus(),
|
|
13432
|
+
label: "Federation sync driver",
|
|
13433
|
+
enableCommand: "flair federation sync enable",
|
|
13434
|
+
statusCommand: "flair federation sync status",
|
|
13435
|
+
darwinTarget: `${guiDomain}/${fedSched.LAUNCHD_LABEL}`,
|
|
13436
|
+
linuxServiceUnit: fedSched.SYSTEMD_SERVICE_UNIT,
|
|
13437
|
+
stderrLogPath: join(homedir(), ".flair", "logs", "federation-sync.stderr.log"),
|
|
13438
|
+
},
|
|
13439
|
+
{
|
|
13440
|
+
status: remSched.schedulerStatus(),
|
|
13441
|
+
label: "REM nightly driver",
|
|
13442
|
+
enableCommand: "flair rem nightly enable",
|
|
13443
|
+
statusCommand: "flair rem nightly status",
|
|
13444
|
+
darwinTarget: `${guiDomain}/${remSched.LAUNCHD_LABEL}`,
|
|
13445
|
+
linuxServiceUnit: remSched.SYSTEMD_SERVICE_UNIT,
|
|
13446
|
+
stderrLogPath: join(homedir(), ".flair", "logs", "rem-nightly.stderr.log"),
|
|
13447
|
+
},
|
|
13448
|
+
];
|
|
13449
|
+
for (const d of drivers) {
|
|
13450
|
+
// Read the last run only when the service manager actually has the
|
|
13451
|
+
// job — "not installed" and "not loaded" carry their own findings,
|
|
13452
|
+
// and layering a last-exit read on top would blur which actor failed.
|
|
13453
|
+
const lastExit = d.status.installed && d.status.active === true
|
|
13454
|
+
? queryLastExitStatus({ plat: d.status.platform, darwinTarget: d.darwinTarget, linuxServiceUnit: d.linuxServiceUnit })
|
|
13455
|
+
: null;
|
|
13456
|
+
const finding = describeScheduledDriverFinding({
|
|
13457
|
+
label: d.label,
|
|
13458
|
+
enableCommand: d.enableCommand,
|
|
13459
|
+
statusCommand: d.statusCommand,
|
|
13460
|
+
installed: d.status.installed,
|
|
13461
|
+
active: d.status.active,
|
|
13462
|
+
lastExit,
|
|
13463
|
+
stderrLogPath: d.stderrLogPath,
|
|
13464
|
+
});
|
|
13465
|
+
console.log(` ${render.icons[finding.icon]} ${finding.message}`);
|
|
13466
|
+
finding.detail.forEach((line, i) => {
|
|
13467
|
+
// Embed-verify degraded style: the actor+state line loud (red),
|
|
13468
|
+
// the remedy dim.
|
|
13469
|
+
const color = finding.state === "degraded" && i === 0 ? render.c.red : render.c.dim;
|
|
13470
|
+
console.log(` ${render.wrap(color, line)}`);
|
|
13471
|
+
});
|
|
13472
|
+
if (finding.isIssue)
|
|
13473
|
+
issues++;
|
|
13474
|
+
}
|
|
13475
|
+
}
|
|
13476
|
+
catch (err) {
|
|
13477
|
+
// An unsupported platform (neither darwin nor linux) or a broken unit
|
|
13478
|
+
// read must not take down doctor — report the section as unchecked
|
|
13479
|
+
// (UNVERIFIED, not a pass), same as the other probes' skip discipline.
|
|
13480
|
+
console.log(` ${render.icons.warn} Scheduled drivers: could not check ${render.wrap(render.c.dim, `(${err?.message ?? err})`)}`);
|
|
13481
|
+
}
|
|
13227
13482
|
// Summary — see summarizeDoctorRun above (flair#721): distinguishes
|
|
13228
13483
|
// issues --fix actually resolved this run from ones still outstanding.
|
|
13229
13484
|
console.log("");
|
|
@@ -14297,6 +14552,29 @@ sessionSnapshot
|
|
|
14297
14552
|
console.error(` extracted to: ${targetDir}`);
|
|
14298
14553
|
});
|
|
14299
14554
|
// ─── Memory and Soul commands ────────────────────────────────────────────────
|
|
14555
|
+
// ─── --entities <csv> (flair#1288) ──────────────────────────────────────────
|
|
14556
|
+
//
|
|
14557
|
+
// Shared parse+validate for the `--entities <csv>` option on `memory add`,
|
|
14558
|
+
// `workspace set`, and `orgevent`. Comma-delimited to match the existing CLI
|
|
14559
|
+
// list-option convention (`--tags <csv>`, `--derived-from <csv>`); safe
|
|
14560
|
+
// because no entity grammar admits a comma. Invalid input exits 1 with the
|
|
14561
|
+
// canonical message — it names the offending values, the `type:value`
|
|
14562
|
+
// format, and the closed type set (errors must enable a response; same hint
|
|
14563
|
+
// the attention path's server-side invalid_entity 400 carries). The server
|
|
14564
|
+
// still re-validates on every write path (resources/entity-vocab.ts via
|
|
14565
|
+
// Memory/WorkspaceState/OrgEvent) — this client-side gate exists so a typo
|
|
14566
|
+
// is caught before any signing/network work, with a message a raw 400 body
|
|
14567
|
+
// never matched.
|
|
14568
|
+
function parseEntitiesOptionOrExit(csv) {
|
|
14569
|
+
const { entities, invalid } = parseEntitiesCsv(csv);
|
|
14570
|
+
if (invalid.length > 0) {
|
|
14571
|
+
console.error(`error: invalid --entities value${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`);
|
|
14572
|
+
console.error(` ${entityFormatHint()}`);
|
|
14573
|
+
process.exit(1);
|
|
14574
|
+
}
|
|
14575
|
+
return entities;
|
|
14576
|
+
}
|
|
14577
|
+
const ENTITIES_OPTION_DESCRIPTION = "Comma-separated entity vocabulary strings this record touches (type:value from the closed type set, e.g. repo:tpsdev-ai/flair — see docs/entity-vocabulary.md; feeds `flair attention`)";
|
|
14300
14578
|
const memory = program.command("memory").description("Manage agent memories");
|
|
14301
14579
|
memory.command("add [content]")
|
|
14302
14580
|
.description("Write a new memory row for an agent (content via positional arg or --content)")
|
|
@@ -14307,6 +14585,7 @@ memory.command("add [content]")
|
|
|
14307
14585
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
14308
14586
|
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
14309
14587
|
.option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to any other agent) or 'shared' (visible to owner + every other agent on this instance — open within the org, not gated by a MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509)")
|
|
14588
|
+
.option("--entities <csv>", ENTITIES_OPTION_DESCRIPTION)
|
|
14310
14589
|
.action(async (contentArg, opts) => {
|
|
14311
14590
|
const content = contentArg ?? opts.content;
|
|
14312
14591
|
if (!content) {
|
|
@@ -14342,6 +14621,13 @@ memory.command("add [content]")
|
|
|
14342
14621
|
if (opts.derivedFrom) {
|
|
14343
14622
|
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
14344
14623
|
}
|
|
14624
|
+
// flair#1288: validated client-side; exits 1 with the canonical
|
|
14625
|
+
// format-and-type-set message on any malformed value.
|
|
14626
|
+
if (opts.entities) {
|
|
14627
|
+
const entities = parseEntitiesOptionOrExit(String(opts.entities));
|
|
14628
|
+
if (entities.length > 0)
|
|
14629
|
+
body.entities = entities;
|
|
14630
|
+
}
|
|
14345
14631
|
const out = await api("PUT", `/Memory/${memId}`, body, { agentId });
|
|
14346
14632
|
console.log(JSON.stringify(out, null, 2));
|
|
14347
14633
|
});
|
|
@@ -16601,6 +16887,7 @@ workspace
|
|
|
16601
16887
|
.option("--task <id>", "Task/issue id this workspace is attached to")
|
|
16602
16888
|
.option("--phase <phase>", "Current phase (e.g. design, implement, review)")
|
|
16603
16889
|
.option("--summary <text>", "Short summary of current workspace state")
|
|
16890
|
+
.option("--entities <csv>", ENTITIES_OPTION_DESCRIPTION)
|
|
16604
16891
|
.option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
|
|
16605
16892
|
.option("--port <port>", "Harper HTTP port")
|
|
16606
16893
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
@@ -16617,6 +16904,9 @@ workspace
|
|
|
16617
16904
|
process.exit(1);
|
|
16618
16905
|
}
|
|
16619
16906
|
}
|
|
16907
|
+
// flair#1288: validate --entities before any key/network work; exits 1
|
|
16908
|
+
// with the canonical format-and-type-set message on any malformed value.
|
|
16909
|
+
const entities = opts.entities ? parseEntitiesOptionOrExit(String(opts.entities)) : undefined;
|
|
16620
16910
|
const keyPath = resolveKeyPath(agentId);
|
|
16621
16911
|
if (!keyPath) {
|
|
16622
16912
|
console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
|
|
@@ -16649,6 +16939,8 @@ workspace
|
|
|
16649
16939
|
body.phase = opts.phase;
|
|
16650
16940
|
if (opts.summary)
|
|
16651
16941
|
body.summary = opts.summary;
|
|
16942
|
+
if (entities && entities.length > 0)
|
|
16943
|
+
body.entities = entities;
|
|
16652
16944
|
const res = await fetch(`${baseUrl}/WorkspaceState/${id}`, {
|
|
16653
16945
|
method: "PUT",
|
|
16654
16946
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -16723,6 +17015,8 @@ async function publishOrgEvent(params) {
|
|
|
16723
17015
|
body.scope = params.scope;
|
|
16724
17016
|
if (params.targetIds && params.targetIds.length > 0)
|
|
16725
17017
|
body.targetIds = params.targetIds;
|
|
17018
|
+
if (params.entities && params.entities.length > 0)
|
|
17019
|
+
body.entities = params.entities;
|
|
16726
17020
|
const res = await fetch(`${params.baseUrl}/OrgEvent/${id}`, {
|
|
16727
17021
|
method: "PUT",
|
|
16728
17022
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -16743,6 +17037,7 @@ program
|
|
|
16743
17037
|
.option("--detail <text>", "Longer detail payload")
|
|
16744
17038
|
.option("--scope <scope>", "Scope of the event (e.g. an agent id, repo, or 'org')")
|
|
16745
17039
|
.option("--target <agentId>", "Recipient agent id (repeatable)", (val, acc) => { acc.push(val); return acc; }, [])
|
|
17040
|
+
.option("--entities <csv>", ENTITIES_OPTION_DESCRIPTION)
|
|
16746
17041
|
.option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
|
|
16747
17042
|
.option("--port <port>", "Harper HTTP port")
|
|
16748
17043
|
.option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
@@ -16756,6 +17051,9 @@ program
|
|
|
16756
17051
|
// --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
|
|
16757
17052
|
const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
|
|
16758
17053
|
const targetIds = Array.isArray(opts.target) && opts.target.length > 0 ? opts.target : undefined;
|
|
17054
|
+
// flair#1288: validate --entities before any key/network work; exits 1
|
|
17055
|
+
// with the canonical format-and-type-set message on any malformed value.
|
|
17056
|
+
const entities = opts.entities ? parseEntitiesOptionOrExit(String(opts.entities)) : undefined;
|
|
16759
17057
|
const result = await publishOrgEvent({
|
|
16760
17058
|
agentId,
|
|
16761
17059
|
baseUrl,
|
|
@@ -16764,6 +17062,7 @@ program
|
|
|
16764
17062
|
detail: opts.detail,
|
|
16765
17063
|
scope: opts.scope,
|
|
16766
17064
|
targetIds,
|
|
17065
|
+
entities,
|
|
16767
17066
|
});
|
|
16768
17067
|
if (!result.ok) {
|
|
16769
17068
|
console.error(`Error: ${result.error}`);
|
|
@@ -16884,6 +17183,26 @@ program
|
|
|
16884
17183
|
// its Node-version check passes. The shim imports this module, so import.meta.main
|
|
16885
17184
|
// is false there — without this explicit entry point the CLI would load but never run.
|
|
16886
17185
|
async function runCli() {
|
|
17186
|
+
// flair#1134 — npm ≥12 blocks install scripts by default, so the
|
|
17187
|
+
// postinstall PATH warning cannot fire there; the first thing of ours
|
|
17188
|
+
// that executes is this CLI, reached via npx / absolute path / a PATH
|
|
17189
|
+
// fixed-for-one-shell. Spawn-free (prefix derived from this file's own
|
|
17190
|
+
// location), validated (the derived bin dir must really hold flair),
|
|
17191
|
+
// TTY-gated (no per-run noise for automation), and skipped for `doctor`,
|
|
17192
|
+
// which prints the full finding itself. Must never break the CLI.
|
|
17193
|
+
if (process.argv[2] !== "doctor") {
|
|
17194
|
+
try {
|
|
17195
|
+
const banner = cliBootPathWarning({
|
|
17196
|
+
packageDir: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
|
17197
|
+
pathEnv: process.env.PATH,
|
|
17198
|
+
shell: process.env.SHELL,
|
|
17199
|
+
stderrIsTTY: process.stderr.isTTY === true,
|
|
17200
|
+
});
|
|
17201
|
+
if (banner)
|
|
17202
|
+
console.error(banner);
|
|
17203
|
+
}
|
|
17204
|
+
catch { /* a diagnostic must never take down the CLI */ }
|
|
17205
|
+
}
|
|
16887
17206
|
// A bare `flair` (no command) is a help request, not a usage error — show
|
|
16888
17207
|
// help and exit 0, rather than commander's default (help + exit 1). Flags
|
|
16889
17208
|
// like -h/--help/-v have argv beyond the binary and fall through to
|