@tpsdev-ai/flair 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,17 +16,28 @@ Runs on a laptop, a VPS, or anywhere Node does. Needs **Node.js 22+**.
16
16
  # 1. Install the CLI (no sudo)
17
17
  npm install -g @tpsdev-ai/flair
18
18
 
19
- # 2. Bootstrap the instance and register an agent
19
+ # 2. Verify the command is on your PATH
20
+ flair --version
21
+
22
+ # 3. Bootstrap the instance and register an agent
20
23
  flair init --agent mybot
21
24
 
22
- # 3. Write a memory
25
+ # 4. Write a memory
23
26
  flair memory add --agent mybot "Harper v5 sandbox blocks node:module but process.dlopen works"
24
27
 
25
- # 4. Find it back by meaning, not by keyword
28
+ # 5. Find it back by meaning, not by keyword
26
29
  flair search --agent mybot "native addon loading in sandboxed runtimes"
27
30
  ```
28
31
 
29
- Step 4 finds the memory you never keyword-matched:
32
+ If `flair --version` prints `command not found`, your npm global bin directory is not on `PATH`:
33
+
34
+ ```bash
35
+ export PATH="$(npm prefix -g)/bin:$PATH"
36
+ ```
37
+
38
+ Re-run `flair --version` to confirm, then continue.
39
+
40
+ Step 5 finds the memory you never keyword-matched:
30
41
 
31
42
  ```
32
43
  Harper v5 sandbox blocks node:module but process.dlopen works
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.50.0",
3
- "commit": "d643b7f8addb9fd033209fad1d405f7c935a7247",
4
- "builtAt": "2026-08-25T23:24:38.875Z",
2
+ "version": "0.51.0",
3
+ "commit": "92b5d12cc3aafe892e90af657f073ef88b180ba6",
4
+ "builtAt": "2026-08-30T16:10:24.457Z",
5
5
  "builder": "tsc"
6
6
  }
package/dist/cli.js CHANGED
@@ -32,7 +32,8 @@ import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing
32
32
  import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
33
33
  import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
34
34
  import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
35
- import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
35
+ import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
36
+ import { applyUpgradeHookConsent, catalogIssueDelta, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
36
37
  // Value-only static import so `--interval`'s advertised default cannot drift
37
38
  // from the one the scheduler actually validates against. The module itself is
38
39
  // still loaded lazily at call time (the `await import()`s below) for the
@@ -60,14 +61,18 @@ function signBody(body, secretKey) {
60
61
  const sig = nacl.sign.detached(message, secretKey);
61
62
  return Buffer.from(sig).toString("base64url");
62
63
  }
63
- // Per-record principalId (federation-edge-hardening slice 3a) INFORMATIONAL
64
- // only; the receiver (resources/Federation.ts) never treats it as verified
65
- // identity or uses it in any auth decision. Sourced from the write-time
66
- // provenance stamp (memory-provenance slice 1, Memory.ts's buildProvenance)
67
- // when present. `provenance` is persisted as a JSON STRING (not an object),
68
- // so it must be parsed a raw `row.provenance?.verified?.agentId` would
69
- // silently always be undefined. Soul/Agent/Relationship rows never carry a
70
- // provenance stamp today, so this is a no-op for them.
64
+ // Per-record principalId (federation-edge-hardening slice 3a / flair#1416).
65
+ // Sourced from the write-time provenance stamp (memory-provenance slice 1,
66
+ // Memory.ts's buildProvenance) when present. `provenance` is persisted as
67
+ // a JSON STRING (not an object), so it must be parsed — a raw
68
+ // `row.provenance?.verified?.agentId` would silently always be undefined.
69
+ // Soul/Agent/Relationship rows never carry a provenance stamp today, so
70
+ // this is a no-op for them (those tables are not principal-owning).
71
+ //
72
+ // As of v:2 this value is IN the signed payload. The receiver validates
73
+ // it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
74
+ // no longer informational-only. Credential.principalId is an unrelated
75
+ // owner field — do not grep that path when changing this one.
71
76
  function principalIdFromRow(row) {
72
77
  if (typeof row?.provenance !== "string" || row.provenance.length === 0)
73
78
  return undefined;
@@ -3002,7 +3007,7 @@ program
3002
3007
  .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
3003
3008
  .option("--skip-smoke", "Skip the MCP smoke test")
3004
3009
  .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
3005
- .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code only)")
3010
+ .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code and Codex)")
3006
3011
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
3007
3012
  .option("--remote", "When used with --target, init as hub for remote federation")
3008
3013
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
@@ -3864,6 +3869,18 @@ program
3864
3869
  }
3865
3870
  wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
3866
3871
  console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
3872
+ // Codex SessionStart hook (flair#1148 / #1439) — the hook is not
3873
+ // optional on Codex (no CLAUDE.md alternative). Init is the
3874
+ // consent to set up the client, same as the Claude Code hook
3875
+ // applied above. --skip-hook opts out and prints the JSON.
3876
+ if (clientId === "codex" && result.ok) {
3877
+ const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook, hookSettingsPath(homedir(), "codex"));
3878
+ console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
3879
+ if (hookResult.hint) {
3880
+ for (const line of hookResult.hint.split("\n"))
3881
+ console.log(` ${line}`);
3882
+ }
3883
+ }
3867
3884
  }
3868
3885
  }
3869
3886
  }
@@ -7030,22 +7047,40 @@ export async function runFederationSyncOnce(opts) {
7030
7047
  // batch. Closes the hub-relay forgery hole — see
7031
7048
  // resources/Federation.ts FederationSync.post's verification gate.
7032
7049
  //
7033
- // CONTRACT — must match Federation.ts's verification payload
7034
- // byte-for-byte: keys { v, table, id, data, updatedAt,
7035
- // originatorInstanceId }. canonicalize() sorts keys, so field ORDER
7036
- // doesn't matter, but the field SET and values do. `v: 1` versions the
7037
- // canonical form itself: bump it on BOTH sides together if the signed
7038
- // field set ever changes, so an old signature fails closed instead of
7039
- // silently mis-verifying under a new form.
7050
+ // CONTRACT — must match reconstructRecordVerifyBody
7051
+ // (resources/federation-classify.ts) byte-for-byte. canonicalize()
7052
+ // sorts keys, so field ORDER doesn't matter, but the field SET and
7053
+ // values do. `v` versions the canonical form itself: a v:1
7054
+ // signature cannot verify as v:2 (principalId in the field set).
7040
7055
  //
7041
- // Additive/backward-compatible: pre-3a receivers don't read
7042
- // `signature`/`principalId` at all and merge exactly as before.
7043
- const signature = signBody({ v: 1, table, id: row.id, data: row, updatedAt, originatorInstanceId }, secretKey);
7044
- const sr = { table, id: row.id, data: row, updatedAt, originatorInstanceId, signature };
7045
- // Informational only (see principalIdFromRow) never verified by the
7046
- // receiver as proof of authorship. Omitted entirely when the row
7047
- // carries no write-time provenance stamp.
7056
+ // v: 2 puts principalId in the signed payload when the row carries
7057
+ // a provenance stamp, and puts `v` on the wire so Phase 1
7058
+ // receivers (`const v = record.v ?? 1`) don't default these
7059
+ // records back to 1. Soul/Agent/Relationship have no stamp and
7060
+ // omit principalId; Memory without a stamp also omits it (the
7061
+ // receiver then skips Memory as principal_mismatch absent is
7062
+ // not an accept).
7048
7063
  const principalId = principalIdFromRow(row);
7064
+ const signedPayload = {
7065
+ v: 2,
7066
+ table,
7067
+ id: row.id,
7068
+ data: row,
7069
+ updatedAt,
7070
+ originatorInstanceId,
7071
+ };
7072
+ if (principalId)
7073
+ signedPayload.principalId = principalId;
7074
+ const signature = signBody(signedPayload, secretKey);
7075
+ const sr = {
7076
+ v: 2,
7077
+ table,
7078
+ id: row.id,
7079
+ data: row,
7080
+ updatedAt,
7081
+ originatorInstanceId,
7082
+ signature,
7083
+ };
7049
7084
  if (principalId)
7050
7085
  sr.principalId = principalId;
7051
7086
  const srBytes = JSON.stringify(sr).length;
@@ -10276,6 +10311,7 @@ program
10276
10311
  .option("--project <name>", "Fabric component name for --target", "flair")
10277
10312
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
10278
10313
  .option("--yes", "Skip the confirmation prompt for --target")
10314
+ .option("--install-hooks", "Consent to installing missing SessionStart hooks (claude-code / Codex) during upgrade. The hook executes at every session start — upgrade will not write it unprompted. Interactive runs prompt; non-interactive runs state the gap and withhold ✅ unless this flag is passed.")
10279
10315
  .option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
10280
10316
  // ── flair#878 ─────────────────────────────────────────────────────────────
10281
10317
  // These existed on `flair deploy` but stopped at the upgrade boundary, so
@@ -10941,20 +10977,16 @@ program
10941
10977
  });
10942
10978
  const verdict = decideAfterVerify(verify, previousFlairVersion);
10943
10979
  if (verdict.kind === "ok") {
10944
- // flair#1022: the verified facts are unchanged and still stated — the
10945
- // upgrade did land. What changes is the MARKER and the claim around it.
10946
- // A run that ended up outside its process manager has not fully
10947
- // succeeded, so it does not get a ✅, and the line names the property
10948
- // that is wrong rather than only the ones that are right. The choice
10949
- // lives in renderVerifiedSummary so it is testable without performing an
10950
- // upgrade — no CI lane runs this darwin path.
10951
- const summary = renderVerifiedSummary(verify.version, management);
10952
- for (const line of summary.lines) {
10953
- if (summary.degraded)
10954
- console.error(line);
10955
- else
10956
- console.log(line);
10957
- }
10980
+ // flair#1439: the success marker is the doctor runner's verdict, not
10981
+ // a second, narrower notion of "verified". Launchd detach is one
10982
+ // catalog member; the Codex SessionStart hook is another. Adding a
10983
+ // doctor check widens this claim automatically.
10984
+ const run = await doctorRunAfterUpgrade({
10985
+ management,
10986
+ port,
10987
+ installHooksFlag: !!opts.installHooks,
10988
+ });
10989
+ printVerifiedSummary(renderVerifiedSummary(verify.version, run));
10958
10990
  return;
10959
10991
  }
10960
10992
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -10966,21 +10998,23 @@ program
10966
10998
  // "print an honest note but roll back anyway" branch that used to sit below
10967
10999
  // is gone — that credentials case can no longer reach the rollback path.)
10968
11000
  if (verdict.kind === "healthy-unverified") {
10969
- // flair#1022: same rule as the "ok" branch above the is withheld
10970
- // when the run left the instance outside launchd, and the reason is
10971
- // named. This branch already qualifies the version claim; the process
10972
- // manager is a second, independent qualification.
10973
- console.log(detached
10974
- ? `⚠️ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}, but NOT under launchd.`
10975
- : `✅ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}.`);
11001
+ // Same doctor runner as the "ok" branch — an unverified version must
11002
+ // not restore the unqualified while a catalog member is failing.
11003
+ const run = await doctorRunAfterUpgrade({
11004
+ management,
11005
+ port,
11006
+ installHooksFlag: !!opts.installHooks,
11007
+ });
11008
+ const versionNote = expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : "";
11009
+ if (run.healthy) {
11010
+ console.log(`✅ upgrade complete: the instance is up and healthy${versionNote}.`);
11011
+ }
11012
+ else {
11013
+ printVerifiedSummary(renderVerifiedSummary(verify.version, run, { authenticated: false }));
11014
+ }
10976
11015
  console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
10977
11016
  console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
10978
11017
  console.log(" To enable full post-upgrade verification: set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key.");
10979
- if (detached) {
10980
- for (const line of renderDetachedWarning(management, "The instance is NOT running under launchd.")) {
10981
- console.error(line);
10982
- }
10983
- }
10984
11018
  return;
10985
11019
  }
10986
11020
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -11299,6 +11333,83 @@ function resolveInstanceServingPid(dataDir, port) {
11299
11333
  listeningPids,
11300
11334
  });
11301
11335
  }
11336
+ /** Agent signing-key ids under `keysDir` — node-scoped federation keys excluded. */
11337
+ function collectKeyAgentIds(keysDir) {
11338
+ if (!existsSync(keysDir))
11339
+ return [];
11340
+ try {
11341
+ const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
11342
+ const { agentKeyIds } = partitionKeyIds(keyFiles.map((f) => f.replace(/\.key$/, "")), keysDir);
11343
+ return agentKeyIds;
11344
+ }
11345
+ catch {
11346
+ return [];
11347
+ }
11348
+ }
11349
+ async function confirmYes(question) {
11350
+ if (!process.stdin.isTTY)
11351
+ return false;
11352
+ const { createInterface } = await import("node:readline");
11353
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11354
+ const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
11355
+ return /^y(es)?$/i.test(answer.trim());
11356
+ }
11357
+ /**
11358
+ * flair#1439 — run the enumerable doctor catalog after upgrade's instance
11359
+ * probe, and offer a consented SessionStart-hook install when that check
11360
+ * fails. Silent writes are refused: `--install-hooks` or an interactive
11361
+ * yes is the only consent. The consent→write composition lives in
11362
+ * applyUpgradeHookConsent so tests can drive the path that actually
11363
+ * writes (or does not write) the hook file.
11364
+ */
11365
+ async function doctorRunAfterUpgrade(args) {
11366
+ const homeDir = homedir();
11367
+ const keysDir = defaultKeysDir();
11368
+ const ctx = {
11369
+ homeDir,
11370
+ cwd: process.cwd(),
11371
+ detectedClientIds: detectClients().filter((c) => c.detected).map((c) => c.id),
11372
+ launchd: args.management,
11373
+ keysDir,
11374
+ keyAgentIds: collectKeyAgentIds(keysDir),
11375
+ };
11376
+ const run = runDoctorChecks(ctx);
11377
+ const apply = (promptAccepted) => applyUpgradeHookConsent({
11378
+ homeDir,
11379
+ ctx,
11380
+ run,
11381
+ installHooksFlag: args.installHooksFlag,
11382
+ interactive: !!process.stdin.isTTY,
11383
+ promptAccepted,
11384
+ port: args.port,
11385
+ });
11386
+ let outcome = apply();
11387
+ if (outcome.consent === "prompt" && outcome.prompt) {
11388
+ console.log("");
11389
+ for (const line of outcome.prompt.preamble)
11390
+ console.log(` ${line}`);
11391
+ outcome = apply(await confirmYes(outcome.prompt.question));
11392
+ }
11393
+ if (outcome.consent === "install") {
11394
+ for (const w of outcome.writes) {
11395
+ console.log(` ${w.ok ? "✓" : "•"} ${w.message}`);
11396
+ }
11397
+ }
11398
+ if (outcome.consent === "skip-noninteractive") {
11399
+ for (const line of outcome.messages) {
11400
+ console.error(` ${line}`);
11401
+ }
11402
+ }
11403
+ return outcome.run;
11404
+ }
11405
+ function printVerifiedSummary(summary) {
11406
+ for (const line of summary.lines) {
11407
+ if (summary.degraded)
11408
+ console.error(line);
11409
+ else
11410
+ console.log(line);
11411
+ }
11412
+ }
11302
11413
  /**
11303
11414
  * Observe whether `dataDir`'s instance is running under launchd right now.
11304
11415
  *
@@ -13057,6 +13168,21 @@ program
13057
13168
  return /^y(es)?$/i.test(answer.trim());
13058
13169
  }
13059
13170
  const detectedClients = detectClients().filter((c) => c.detected);
13171
+ // flair#1439 — install-health (MCP, FLAIR_URL, CLAUDE.md, SessionStart
13172
+ // hook, verified-read plan, keys classification, launchd) is the same
13173
+ // catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
13174
+ // both. Extra doctor UX (pi, --fix, execution probe, continuity,
13175
+ // agent registration) stays below and does not redefine those checks.
13176
+ const doctorCtx = {
13177
+ homeDir: homedir(),
13178
+ cwd: process.cwd(),
13179
+ detectedClientIds: detectedClients.map((c) => c.id),
13180
+ launchd: observeLaunchdManagement(defaultDataDir(), effectivePort),
13181
+ keysDir,
13182
+ keyAgentIds,
13183
+ agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
13184
+ };
13185
+ const catalogBefore = runDoctorChecks(doctorCtx);
13060
13186
  if (detectedClients.length === 0) {
13061
13187
  console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
13062
13188
  }
@@ -13291,7 +13417,6 @@ program
13291
13417
  wireCursor(wireEnv);
13292
13418
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
13293
13419
  if (wireResult.ok) {
13294
- fixed++;
13295
13420
  if (client.id === "claude-code")
13296
13421
  claudeCodeAgentId = fixAgentId;
13297
13422
  if (client.id === "codex")
@@ -13311,7 +13436,6 @@ program
13311
13436
  const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
13312
13437
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
13313
13438
  }
13314
- issues++;
13315
13439
  continue;
13316
13440
  }
13317
13441
  console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
@@ -13369,15 +13493,12 @@ program
13369
13493
  else {
13370
13494
  const fixRes = fixClaudeMdBootstrap(process.cwd());
13371
13495
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13372
- if (fixRes.ok)
13373
- fixed++;
13374
13496
  }
13375
13497
  }
13376
13498
  }
13377
13499
  else {
13378
13500
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the mcp__flair__bootstrap line to ./CLAUDE.md)")}`);
13379
13501
  }
13380
- issues++;
13381
13502
  }
13382
13503
  // flair#1007: presence was never the problem — the failing entry was
13383
13504
  // perfectly well-formed. inspectSessionStartHook() additionally RUNS
@@ -13446,8 +13567,6 @@ program
13446
13567
  else {
13447
13568
  const upgrade = upgradeSessionStartHookCommand(homedir());
13448
13569
  console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
13449
- if (upgrade.ok && upgrade.changed)
13450
- fixed++;
13451
13570
  }
13452
13571
  }
13453
13572
  }
@@ -13458,7 +13577,6 @@ program
13458
13577
  else {
13459
13578
  console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install`);
13460
13579
  }
13461
- issues++;
13462
13580
  }
13463
13581
  }
13464
13582
  else {
@@ -13476,15 +13594,12 @@ program
13476
13594
  const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
13477
13595
  const fixRes = fixSessionStartHook(homedir(), fixAgentId);
13478
13596
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13479
- if (fixRes.ok)
13480
- fixed++;
13481
13597
  }
13482
13598
  }
13483
13599
  }
13484
13600
  else {
13485
13601
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
13486
13602
  }
13487
- issues++;
13488
13603
  }
13489
13604
  // flair#1257 slice 2 — continuity capture pair (the check-5 twin of
13490
13605
  // the SessionStart check above: installed / absent / stale-form).
@@ -13584,8 +13699,6 @@ program
13584
13699
  else {
13585
13700
  const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
13586
13701
  console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
13587
- if (upgrade.ok && upgrade.changed)
13588
- fixed++;
13589
13702
  }
13590
13703
  }
13591
13704
  }
@@ -13596,7 +13709,6 @@ program
13596
13709
  else {
13597
13710
  console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install --harness codex`);
13598
13711
  }
13599
- issues++;
13600
13712
  }
13601
13713
  }
13602
13714
  else {
@@ -13614,18 +13726,27 @@ program
13614
13726
  const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
13615
13727
  const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
13616
13728
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13617
- if (fixRes.ok)
13618
- fixed++;
13619
13729
  }
13620
13730
  }
13621
13731
  }
13622
13732
  else {
13623
13733
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
13624
13734
  }
13625
- issues++;
13626
13735
  }
13627
13736
  }
13628
13737
  }
13738
+ // Catalog is the install-health verdict — count fail/unrun here, not
13739
+ // via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
13740
+ // --fix that cleared a catalog member shows up in the found→fixed delta.
13741
+ const catalogAfter = autoFix ? runDoctorChecks(doctorCtx) : catalogBefore;
13742
+ const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
13743
+ issues += catalogDelta.found;
13744
+ if (autoFix)
13745
+ fixed += catalogDelta.fixed;
13746
+ console.log(`\n ${render.wrap(render.c.bold, "Install health")}`);
13747
+ for (const row of renderCatalogDoctorLines(catalogAfter)) {
13748
+ console.log(` ${render.icons[row.icon]} ${row.line}`);
13749
+ }
13629
13750
  // 7a. Resolve which agent identities the two verified-read sections below
13630
13751
  // (Fleet presence, Migrations) iterate (flair#722). Previously both
13631
13752
  // sections required --agent explicitly; doctor already enumerates every
@@ -1117,8 +1117,8 @@ function sessionStartHookHint(agentId, path) {
1117
1117
  * after the hook is present (whether from a prior call or already there)
1118
1118
  * reports ok:true, applied:false — safe to call on every `flair init`.
1119
1119
  */
1120
- export function applyOrReportSessionStartHook(homeDir, agentId, skip) {
1121
- const existing = checkSessionStartHook(homeDir);
1120
+ export function applyOrReportSessionStartHook(homeDir, agentId, skip, settingsPath) {
1121
+ const existing = checkSessionStartHook(homeDir, settingsPath);
1122
1122
  if (existing.present) {
1123
1123
  return { applied: false, ok: true, message: `SessionStart hook already wired in ${existing.path}` };
1124
1124
  }
@@ -1126,7 +1126,7 @@ export function applyOrReportSessionStartHook(homeDir, agentId, skip) {
1126
1126
  if (skip) {
1127
1127
  return { applied: false, ok: false, message: "SessionStart hook skipped (--skip-hook)", hint };
1128
1128
  }
1129
- const fix = fixSessionStartHook(homeDir, agentId);
1129
+ const fix = fixSessionStartHook(homeDir, agentId, settingsPath ?? existing.path);
1130
1130
  return { applied: fix.ok, ok: fix.ok, message: fix.message, hint: fix.ok ? undefined : hint };
1131
1131
  }
1132
1132
  // ── check 5: per-agent iteration for verified-read sections (flair#722) ────
@@ -5,7 +5,9 @@
5
5
  // @tpsdev-ai/flair-mcp); pi is a native-extension host (kind:
6
6
  // "native-extension" — wired via pi's own settings.json `packages` key,
7
7
  // flair#1342). Each client has:
8
- // - detection: `bin` on PATH, optionally widened by a declared detect() override
8
+ // - detection: `bin` on PATH OR the client's known config path exists
9
+ // (flair#1417 — GUI-only Cursor, PATH-quirky installs). A declared
10
+ // detect() override remains the exception (pi).
9
11
  // - wire(env): { ok: boolean; message: string }
10
12
  //
11
13
  // Wiring contract (FIX 4 — onboarding dogfood round 1):
@@ -89,12 +91,13 @@ function binInPath(name) {
89
91
  * elsewhere — the same defect already fixed for `flair upgrade`'s presence
90
92
  * probes (see "Upgrade presence probes" in src/cli.ts).
91
93
  *
92
- * Nothing is lost by dropping it: an `npm install -g` links the package's bin
93
- * into the prefix's bin directory, which is on PATH by construction (it is where
94
- * `npm` itself is found from). A client whose binary is NOT on PATH is a client
95
- * the user cannot launch, and wiring an MCP config for it is at best a no-op.
96
- * `flair init --client <name>` still wires a client explicitly, bypassing
97
- * detection entirely, so an exotic install is never locked out.
94
+ * Nothing is lost by dropping the npm-list fallback: an `npm install -g` links
95
+ * the package's bin into the prefix's bin directory, which is on PATH by
96
+ * construction. Detection still asks PATH first. A second signal is the
97
+ * client's known config path (detectClients, flair#1417): a GUI install whose
98
+ * shell command is opt-in (Cursor) is still present when its config file
99
+ * exists. `flair init --client <name>` still wires a client explicitly,
100
+ * bypassing detection entirely, so an exotic install is never locked out.
98
101
  */
99
102
  function detectBin(bin) {
100
103
  try {
@@ -560,10 +563,12 @@ function antigravityConfigPath() {
560
563
  return join(resolveHome(), ".gemini", "config", "mcp_config.json");
561
564
  }
562
565
  /**
563
- * Single dispatcher for "where does this client's MCP config live" — used by
566
+ * Single dispatcher for "where does this client's config live" — used by
564
567
  * `flair doctor`'s client-integration checks (flair#588) to read the config
565
- * without duplicating the per-client path logic that already lives here.
566
- * Additive only: does not change existing wire/detect behavior.
568
+ * without duplicating the per-client path logic that already lives here,
569
+ * and by detectClients() as the config-path presence signal (flair#1417).
570
+ * Every current ClientId has a path; a future id must extend this switch
571
+ * or stay binary-only via a `detect` override — do not invent a path.
567
572
  */
568
573
  export function clientConfigPath(id) {
569
574
  switch (id) {
@@ -758,17 +763,23 @@ export function renderWiringSummary(results, opts = {}) {
758
763
  return lines;
759
764
  }
760
765
  /**
761
- * Detect every known client. One rule (`bin` on PATH) applied uniformly — a
762
- * client added to ALL_CLIENTS is detected by declaring its executable, with no
763
- * per-client branch here to forget to extend. A client may widen that with a
764
- * declared `detect` override (still a pure fs check pi adds its settings
765
- * file as a second signal, flair#1342); the override lives on the registry
766
- * entry, so this function stays branch-free.
766
+ * Detect every known client. One rule applied uniformly: `bin` on PATH OR
767
+ * the client's known config path exists (flair#1417). A client added to
768
+ * ALL_CLIENTS is detected by declaring its executable; clientConfigPath()
769
+ * already names every current id, so the config-path fallback is not a
770
+ * per-client branch to forget. A declared `detect` override remains the
771
+ * exception (still a pure fs check — pi, flair#1342). Detection never
772
+ * spawns a subprocess (flair#946).
773
+ *
774
+ * A future client with no config path helper stays binary-only until it
775
+ * grows one here or a `detect` override — do not invent a path.
767
776
  */
768
777
  export function detectClients() {
769
778
  return ALL_CLIENTS.map((client) => ({
770
779
  ...client,
771
- detected: client.detect ? client.detect() : detectBin(client.bin),
780
+ detected: client.detect
781
+ ? client.detect()
782
+ : detectBin(client.bin) || existsSync(clientConfigPath(client.id)),
772
783
  }));
773
784
  }
774
785
  export function wireClaudeCode(env) {