@tpsdev-ai/flair 0.53.0 → 0.54.2

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.
Files changed (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1665 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +931 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1628 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/engine-version.js +12 -4
  43. package/dist/fabric-npm-install.js +87 -0
  44. package/dist/fabric-upgrade.js +30 -15
  45. package/dist/federation-verify.js +498 -0
  46. package/dist/fleet-verify.js +144 -21
  47. package/dist/install/clients.js +167 -0
  48. package/dist/lib/auth-resolve.js +76 -1
  49. package/dist/lib/daemon-liveness.js +131 -2
  50. package/dist/lib/doctor-config-path.js +61 -0
  51. package/dist/lib/doctor-federation-driver.js +189 -0
  52. package/dist/lib/doctor-run.js +40 -0
  53. package/dist/lib/entity-vocab-cli.js +3 -3
  54. package/dist/lib/federation-pair-identity.js +47 -0
  55. package/dist/lib/launchd-repair.js +5 -4
  56. package/dist/lib/npm-registry.js +578 -0
  57. package/dist/lib/ops-api-bind.js +115 -0
  58. package/dist/lib/owned-pins.js +219 -0
  59. package/dist/lib/uninstall-purge.js +218 -0
  60. package/dist/rem/restore.js +8 -10
  61. package/dist/resources/AgentReadPosition.js +74 -0
  62. package/dist/resources/Federation.js +8 -2
  63. package/dist/resources/Memory.js +4 -3
  64. package/dist/resources/MemoryBootstrap.js +41 -25
  65. package/dist/resources/MemoryCandidate.js +5 -6
  66. package/dist/resources/OrgEventCatchup.js +126 -47
  67. package/dist/resources/agent-read-position-lib.js +83 -0
  68. package/dist/resources/agent-read-position.js +120 -0
  69. package/dist/resources/embeddings-boot.js +32 -0
  70. package/dist/resources/federation-peer-liveness.js +73 -0
  71. package/dist/resources/health.js +68 -19
  72. package/dist/resources/mcp-tools.js +48 -279
  73. package/dist/resources/memory-visibility.js +3 -3
  74. package/dist/resources/migration-boot.js +59 -18
  75. package/dist/resources/migrations/embedding-stamp.js +20 -1
  76. package/dist/resources/migrations/recheck.js +43 -0
  77. package/dist/resources/migrations/runner.js +6 -1
  78. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  79. package/dist/resources/migrations/visibility-backfill.js +2 -2
  80. package/dist/resources/org-event-catchup-lib.js +47 -0
  81. package/dist/resources/record-owner-guard.js +1 -0
  82. package/dist/resources/tool-descriptors/index.js +669 -0
  83. package/dist/stamp-migration-verify.js +163 -0
  84. package/dist/stamp-outstanding.js +144 -0
  85. package/dist/version-check.js +29 -8
  86. package/docs/api-reference.md +4 -2
  87. package/docs/deploying-on-fabric.md +11 -10
  88. package/docs/deployment.md +3 -1
  89. package/docs/federation.md +19 -0
  90. package/docs/hosted-on-fabric.md +3 -3
  91. package/docs/quickstart.md +2 -1
  92. package/docs/releasing.md +20 -6
  93. package/docs/spoke-bringup.md +10 -5
  94. package/docs/standalone-local.md +3 -1
  95. package/docs/upgrade.md +25 -6
  96. package/package.json +4 -4
  97. package/schemas/agent.graphql +15 -0
@@ -38,40 +38,128 @@
38
38
  * authenticated version check, same Fabric admin Basic-auth credentials as
39
39
  * the origin — a Fabric cluster shares one admin realm). A peer with no
40
40
  * usable endpoint is reported "unverifiable" — never silently dropped, never
41
- * printed green. See classifyNode()'s "method" field: "direct" (we actually
42
- * hit it) vs "none" (we could not attempt a check at all, and say why).
41
+ * printed green, and does not fail the run (flair#988). A reachable peer
42
+ * verified on the wrong version still fails as diverged. See classifyNode()'s
43
+ * "method" field: "direct" (we actually hit it) vs "none" (we could not
44
+ * attempt a check at all, and say why).
43
45
  */
44
46
  import { probeInstance } from "./probe.js";
45
47
  import * as render from "./render.js";
46
48
  // ─── Exit codes (documented in `flair fleet verify --help`) ─────────────────
47
- /** All nodes verified: healthy, authenticated, and version-matched. */
49
+ //
50
+ // Three operator-facing states (flair#988). UNVERIFIABLE (couldn't check)
51
+ // must never share an exit with DIVERGED (probed, verified wrong):
52
+ //
53
+ // 1. All probed nodes OK, none diverged → 0 "converged."
54
+ // Unverifiable peers are listed + warned; they do NOT fail the run.
55
+ // 2. Any reachable node DIVERGED (wrong version) → non-zero
56
+ // "NOT converged — <node> diverged."
57
+ // 3. Some peers UNVERIFIABLE, none diverged → 0 + WARNING
58
+ // (same exit as (1); a distinct warning exit would also be fine as
59
+ // long as it is not (2)'s code).
60
+ //
61
+ // Exit 3 remains for a reachable peer we *attempted* to probe (unreachable
62
+ // / auth-failed). That is a failed check, not "no endpoint on file."
63
+ /** All probed nodes verified. Unverifiable peers warn but do not fail (flair#988). */
48
64
  export const FLEET_EXIT_OK = 0;
49
65
  /** Origin failed — unreachable, unauthenticated, or running the wrong version. Worst case: the cluster's entrypoint itself is broken. */
50
66
  export const FLEET_EXIT_ORIGIN_FAILED = 1;
51
- /** Origin is fine, but at least one reachable peer is running a DIFFERENT version — a mixed-version fleet (the #638 scenario). */
67
+ /** Origin is fine, but at least one reachable peer is running a DIFFERENT version — a mixed-version fleet (the #638 scenario). This is DIVERGED. */
52
68
  export const FLEET_EXIT_PEER_SKEW = 2;
53
- /** Origin is fine, no version skew among reachable peers, but at least one peer could not be verified at all (unreachable, auth rejected, or no endpoint on file). */
69
+ /** Origin is fine, no version skew among reachable peers, but a reachable peer we attempted to probe was unreachable or rejected auth. */
54
70
  export const FLEET_EXIT_PEER_UNREACHABLE = 3;
55
71
  export const FLEET_EXIT_DESCRIPTIONS = {
56
- [FLEET_EXIT_OK]: "all nodes verified: healthy, authenticated, version-matched",
72
+ [FLEET_EXIT_OK]: "all probed nodes verified (unverifiable peers are listed as a warning and do not fail)",
57
73
  [FLEET_EXIT_ORIGIN_FAILED]: "origin failed (unreachable, unauthenticated, or wrong version)",
58
- [FLEET_EXIT_PEER_SKEW]: "origin OK, but a reachable peer is running a DIFFERENT version (skew)",
59
- [FLEET_EXIT_PEER_UNREACHABLE]: "origin OK, no skew among reachable peers, but a peer could not be verified at all (unreachable, auth rejected, or no endpoint on file)",
74
+ [FLEET_EXIT_PEER_SKEW]: "origin OK, but a reachable peer diverged (wrong version)",
75
+ [FLEET_EXIT_PEER_UNREACHABLE]: "origin OK, no skew among reachable peers, but a reachable peer was unreachable or rejected auth",
60
76
  };
77
+ function isDivergedStatus(status) {
78
+ return status === "skew";
79
+ }
80
+ function isCheckFailedStatus(status) {
81
+ return status === "unreachable" || status === "auth-failed";
82
+ }
83
+ function formatUnverifiableWarning(unverifiableCount, verifiableCount) {
84
+ return (`${unverifiableCount} peer(s) unverifiable — could not check (no endpoint); ` +
85
+ `converged among the ${verifiableCount} verifiable node(s).`);
86
+ }
87
+ /**
88
+ * Separate "couldn't check" from "verified wrong" (flair#988).
89
+ * Single source of truth for exit code + operator sentences. Unverifiable
90
+ * peers never fail the run; a reachable skew still does.
91
+ */
92
+ export function describeFleetSweep(origin, peers) {
93
+ const unverifiable = peers.filter((p) => p.status === "unverifiable");
94
+ const verifiableOk = [origin, ...peers].filter((p) => p.status === "ok");
95
+ const divergedNodes = [origin, ...peers].filter((p) => isDivergedStatus(p.status));
96
+ const warning = unverifiable.length > 0
97
+ ? formatUnverifiableWarning(unverifiable.length, verifiableOk.length)
98
+ : null;
99
+ if (origin.status !== "ok" && !isDivergedStatus(origin.status)) {
100
+ return {
101
+ kind: "check-failed",
102
+ exitCode: FLEET_EXIT_ORIGIN_FAILED,
103
+ diverged: false,
104
+ divergedNodeId: null,
105
+ unverifiableCount: unverifiable.length,
106
+ verifiableCount: verifiableOk.length,
107
+ summary: `origin ${origin.status} — could not complete check.`,
108
+ warning,
109
+ };
110
+ }
111
+ if (divergedNodes.length > 0) {
112
+ const node = divergedNodes[0];
113
+ return {
114
+ kind: "diverged",
115
+ exitCode: origin.status !== "ok" ? FLEET_EXIT_ORIGIN_FAILED : FLEET_EXIT_PEER_SKEW,
116
+ diverged: true,
117
+ divergedNodeId: node.id,
118
+ unverifiableCount: unverifiable.length,
119
+ verifiableCount: verifiableOk.length,
120
+ summary: `NOT converged — ${node.id} diverged.`,
121
+ warning,
122
+ };
123
+ }
124
+ const checkFailedPeer = peers.find((p) => isCheckFailedStatus(p.status));
125
+ if (checkFailedPeer) {
126
+ return {
127
+ kind: "check-failed",
128
+ exitCode: FLEET_EXIT_PEER_UNREACHABLE,
129
+ diverged: false,
130
+ divergedNodeId: null,
131
+ unverifiableCount: unverifiable.length,
132
+ verifiableCount: verifiableOk.length,
133
+ summary: `peer ${checkFailedPeer.id} ${checkFailedPeer.status} — could not complete check.`,
134
+ warning,
135
+ };
136
+ }
137
+ return {
138
+ kind: "converged",
139
+ exitCode: FLEET_EXIT_OK,
140
+ diverged: false,
141
+ divergedNodeId: null,
142
+ unverifiableCount: unverifiable.length,
143
+ verifiableCount: verifiableOk.length,
144
+ summary: "converged.",
145
+ warning,
146
+ };
147
+ }
61
148
  /**
62
- * Priority order when multiple problems exist at once: an origin failure
63
- * always wins (nothing downstream matters if the entrypoint is broken), then
64
- * skew (a real mixed-version fleet), then unreachable/unverifiable (we
65
- * genuinely don't know that node's state).
149
+ * Priority: origin failure, then reachable skew (DIVERGED), then a reachable
150
+ * peer we attempted and could not complete (unreachable / auth-failed).
151
+ * Unverifiable peers (no endpoint, never paired) do not fail the run.
66
152
  */
67
153
  export function decideFleetExitCode(origin, peers) {
68
- if (origin.status !== "ok")
69
- return FLEET_EXIT_ORIGIN_FAILED;
70
- if (peers.some((p) => p.status === "skew"))
71
- return FLEET_EXIT_PEER_SKEW;
72
- if (peers.some((p) => p.status !== "ok"))
73
- return FLEET_EXIT_PEER_UNREACHABLE;
74
- return FLEET_EXIT_OK;
154
+ return describeFleetSweep(origin, peers).exitCode;
155
+ }
156
+ /**
157
+ * Whether `flair deploy` / `flair upgrade --target` should abort after the
158
+ * sweep. Unverifiable-only is not a failure — that was the #988 bug: every
159
+ * non-OK used to print "deploy is NOT fully converged."
160
+ */
161
+ export function fleetSweepShouldAbort(verdict) {
162
+ return verdict.exitCode !== FLEET_EXIT_OK;
75
163
  }
76
164
  // ─── Pure decision logic: ProbeResult → FleetNodeResult ─────────────────────
77
165
  /**
@@ -241,8 +329,17 @@ export async function sweepFleet(opts, injected) {
241
329
  detail: `GET /FederationPeers on the origin failed: ${peerEnumerationError} — fleet coverage unknown, cannot confirm every peer was even considered`,
242
330
  });
243
331
  }
244
- const exitCode = decideFleetExitCode(origin, peers);
245
- return { target: opts.target, expectVersion, expectVersionSource, origin, peers, peerEnumerationError, exitCode };
332
+ const verdict = describeFleetSweep(origin, peers);
333
+ return {
334
+ target: opts.target,
335
+ expectVersion,
336
+ expectVersionSource,
337
+ origin,
338
+ peers,
339
+ peerEnumerationError,
340
+ exitCode: verdict.exitCode,
341
+ verdict,
342
+ };
246
343
  }
247
344
  // ─── Rendering ───────────────────────────────────────────────────────────────
248
345
  const STATUS_COLOR = {
@@ -287,5 +384,31 @@ export function renderFleetSweepTable(result) {
287
384
  : "no version baseline available (origin unreachable/unauthenticated and no --expect-version given) — skew could not be checked";
288
385
  lines.push("");
289
386
  lines.push(render.wrap(render.c.dim, expectLine));
387
+ const verdictLines = renderFleetSweepVerdict(result.verdict);
388
+ if (verdictLines) {
389
+ lines.push("");
390
+ lines.push(verdictLines);
391
+ }
392
+ return lines.join("\n");
393
+ }
394
+ /**
395
+ * Operator-facing verdict after the per-node table (flair#988).
396
+ * Unverifiable-only → warning, never "NOT converged."
397
+ * A reachable skew → "NOT converged — <node> diverged."
398
+ */
399
+ export function renderFleetSweepVerdict(verdict) {
400
+ const lines = [];
401
+ if (verdict.warning) {
402
+ lines.push(`${render.icons.warn} ${render.wrap(render.c.yellow, `WARNING ${verdict.warning}`)}`);
403
+ }
404
+ if (verdict.kind === "diverged") {
405
+ lines.push(`${render.icons.error} ${render.wrap(render.c.red, verdict.summary)}`);
406
+ }
407
+ else if (verdict.kind === "check-failed") {
408
+ lines.push(`${render.icons.error} ${render.wrap(render.c.red, verdict.summary)}`);
409
+ }
410
+ else if (!verdict.warning) {
411
+ lines.push(`${render.icons.ok} ${render.wrap(render.c.green, verdict.summary)}`);
412
+ }
290
413
  return lines.join("\n");
291
414
  }
@@ -171,6 +171,26 @@ export function appendCodexFlairBlock(raw, env) {
171
171
  const separator = raw.length === 0 ? "" : raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
172
172
  return raw + separator + tomlSnippet(env) + "\n";
173
173
  }
174
+ /**
175
+ * Inverse of appendCodexFlairBlock / replaceCodexFlairBlock: drop the
176
+ * `[mcp_servers.flair]` table and its `[mcp_servers.flair.*]` subtables,
177
+ * leaving every other table untouched. Pure — callers write the result.
178
+ */
179
+ export function removeCodexFlairBlock(raw) {
180
+ const header = "[mcp_servers.flair]";
181
+ const idx = raw.indexOf(header);
182
+ if (idx === -1)
183
+ return raw;
184
+ const before = raw.slice(0, idx);
185
+ const after = raw.slice(idx);
186
+ const nextHeader = after.slice(header.length).search(/\n\[(?!mcp_servers\.flair\.)/);
187
+ const rest = nextHeader === -1 ? "" : after.slice(header.length + nextHeader);
188
+ const restBody = rest.replace(/^\n+/, "");
189
+ const joined = before.replace(/\s+$/, "") + (restBody ? "\n\n" + restBody : "");
190
+ if (joined.trim().length === 0)
191
+ return "";
192
+ return joined.replace(/^\n+/, "").replace(/\s+$/, "\n");
193
+ }
174
194
  /**
175
195
  * flair#1135: does the existing `[mcp_servers.flair]` TOML section carry the
176
196
  * CURRENT pinned mcpServerSpec()? Pure string scan — no TOML parser needed
@@ -657,6 +677,125 @@ function _wireCursor(env) {
657
677
  function _wireAntigravity(env) {
658
678
  return wireJsonMcp(antigravityConfigPath(), "Antigravity", env, "wiring unverified against a real agy — restart Antigravity and confirm the flair tools appear");
659
679
  }
680
+ function displayUnderHome(configPath) {
681
+ const home = resolveHome();
682
+ return configPath.startsWith(home) ? "~" + configPath.slice(home.length) : configPath;
683
+ }
684
+ /**
685
+ * Remove `mcpServers.flair` from a JSON MCP config. Preserves sibling servers
686
+ * and every other top-level key. A no-op when the file or the flair entry is
687
+ * absent. Refuses to write when the file is not a JSON object.
688
+ */
689
+ function unwireJsonMcp(configPath, label) {
690
+ const display = displayUnderHome(configPath);
691
+ if (!existsSync(configPath)) {
692
+ return { ok: true, removed: false, message: `${label}: no config at ${display}` };
693
+ }
694
+ try {
695
+ const raw = readFileSync(configPath, "utf-8").trim();
696
+ if (!raw) {
697
+ return { ok: true, removed: false, message: `${label}: no Flair MCP entry in ${display}` };
698
+ }
699
+ const config = JSON.parse(raw);
700
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
701
+ return { ok: false, removed: false, message: `${label}: refusing to modify a non-object config at ${display}` };
702
+ }
703
+ const servers = config.mcpServers;
704
+ if (!servers || typeof servers !== "object" || Array.isArray(servers) || !("flair" in servers)) {
705
+ return { ok: true, removed: false, message: `${label}: no Flair MCP entry in ${display}` };
706
+ }
707
+ delete servers.flair;
708
+ if (Object.keys(servers).length === 0) {
709
+ delete config.mcpServers;
710
+ }
711
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
712
+ return { ok: true, removed: true, message: `${label}: unwired ${display}` };
713
+ }
714
+ catch (err) {
715
+ const reason = err instanceof Error ? err.message : String(err);
716
+ return { ok: false, removed: false, message: `${label}: could not unwire ${display}: ${reason}` };
717
+ }
718
+ }
719
+ function _unwireClaudeCode() {
720
+ return unwireJsonMcp(join(resolveHome(), ".claude.json"), "Claude Code");
721
+ }
722
+ function _unwireCodex() {
723
+ const path = codexConfigPath();
724
+ const display = "~/.codex/config.toml";
725
+ if (!existsSync(path)) {
726
+ return { ok: true, removed: false, message: `Codex: no config at ${display}` };
727
+ }
728
+ try {
729
+ const raw = readFileSync(path, "utf-8");
730
+ if (!codexConfigHasFlairSection(raw)) {
731
+ return { ok: true, removed: false, message: `Codex: no Flair MCP entry in ${display}` };
732
+ }
733
+ writeFileSync(path, removeCodexFlairBlock(raw));
734
+ return { ok: true, removed: true, message: `Codex: unwired ${display}` };
735
+ }
736
+ catch (err) {
737
+ const reason = err instanceof Error ? err.message : String(err);
738
+ return { ok: false, removed: false, message: `Codex: could not unwire ${display}: ${reason}` };
739
+ }
740
+ }
741
+ function _unwireGemini() {
742
+ return unwireJsonMcp(geminiConfigPath(), "Gemini");
743
+ }
744
+ function _unwireCursor() {
745
+ return unwireJsonMcp(cursorConfigPath(), "Cursor");
746
+ }
747
+ function _unwireAntigravity() {
748
+ return unwireJsonMcp(antigravityConfigPath(), "Antigravity");
749
+ }
750
+ function _unwirePi() {
751
+ const path = piSettingsPath();
752
+ const display = displayUnderHome(path);
753
+ if (!existsSync(path)) {
754
+ return { ok: true, removed: false, message: `pi: no config at ${display}` };
755
+ }
756
+ try {
757
+ const raw = readFileSync(path, "utf-8").trim();
758
+ if (!raw) {
759
+ return { ok: true, removed: false, message: `pi: no Flair extension in ${display}` };
760
+ }
761
+ const config = JSON.parse(raw);
762
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
763
+ return { ok: false, removed: false, message: `pi: refusing to modify a non-object config at ${display}` };
764
+ }
765
+ let changed = false;
766
+ if (Array.isArray(config.packages)) {
767
+ const kept = config.packages.filter((entry) => {
768
+ const source = piPackageEntrySource(entry);
769
+ return !(source && isPiFlairNpmSource(source));
770
+ });
771
+ if (kept.length !== config.packages.length) {
772
+ config.packages = kept;
773
+ changed = true;
774
+ }
775
+ }
776
+ if (Array.isArray(config.extensions)) {
777
+ const kept = config.extensions.filter((entry) => {
778
+ if (typeof entry === "string" && (isPiFlairNpmSource(entry) || isPiFlairExtensionPath(entry))) {
779
+ return false;
780
+ }
781
+ return true;
782
+ });
783
+ if (kept.length !== config.extensions.length) {
784
+ config.extensions = kept;
785
+ changed = true;
786
+ }
787
+ }
788
+ if (!changed) {
789
+ return { ok: true, removed: false, message: `pi: no Flair extension in ${display}` };
790
+ }
791
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
792
+ return { ok: true, removed: true, message: `pi: unwired ${display}` };
793
+ }
794
+ catch (err) {
795
+ const reason = err instanceof Error ? err.message : String(err);
796
+ return { ok: false, removed: false, message: `pi: could not unwire ${display}: ${reason}` };
797
+ }
798
+ }
660
799
  // ---- Exported detection & wiring array ------------------------------------------
661
800
  export const ALL_CLIENTS = [
662
801
  {
@@ -665,6 +804,7 @@ export const ALL_CLIENTS = [
665
804
  bin: "claude",
666
805
  kind: "mcp",
667
806
  wire: _wireClaudeCode,
807
+ unwire: _unwireClaudeCode,
668
808
  },
669
809
  {
670
810
  id: "codex",
@@ -672,6 +812,7 @@ export const ALL_CLIENTS = [
672
812
  bin: "codex",
673
813
  kind: "mcp",
674
814
  wire: _wireCodex,
815
+ unwire: _unwireCodex,
675
816
  },
676
817
  {
677
818
  id: "gemini",
@@ -679,6 +820,7 @@ export const ALL_CLIENTS = [
679
820
  bin: "gemini",
680
821
  kind: "mcp",
681
822
  wire: _wireGemini,
823
+ unwire: _unwireGemini,
682
824
  },
683
825
  {
684
826
  id: "cursor",
@@ -686,6 +828,7 @@ export const ALL_CLIENTS = [
686
828
  bin: "cursor",
687
829
  kind: "mcp",
688
830
  wire: _wireCursor,
831
+ unwire: _unwireCursor,
689
832
  },
690
833
  {
691
834
  id: "antigravity",
@@ -694,6 +837,7 @@ export const ALL_CLIENTS = [
694
837
  bin: "agy",
695
838
  kind: "mcp",
696
839
  wire: _wireAntigravity,
840
+ unwire: _unwireAntigravity,
697
841
  },
698
842
  {
699
843
  id: "pi",
@@ -708,6 +852,7 @@ export const ALL_CLIENTS = [
708
852
  // a pi whose wiring is worth checking/fixing. Pure fs check, both legs.
709
853
  detect: () => detectBin("pi") || existsSync(piSettingsPath()),
710
854
  wire: _wirePi,
855
+ unwire: _unwirePi,
711
856
  },
712
857
  ];
713
858
  /**
@@ -800,3 +945,25 @@ export function wireAntigravity(env) {
800
945
  export function wirePi(env) {
801
946
  return _wirePi(env);
802
947
  }
948
+ export function unwireClaudeCode() {
949
+ return _unwireClaudeCode();
950
+ }
951
+ export function unwireCodex() {
952
+ return _unwireCodex();
953
+ }
954
+ export function unwireGemini() {
955
+ return _unwireGemini();
956
+ }
957
+ export function unwireCursor() {
958
+ return _unwireCursor();
959
+ }
960
+ export function unwireAntigravity() {
961
+ return _unwireAntigravity();
962
+ }
963
+ export function unwirePi() {
964
+ return _unwirePi();
965
+ }
966
+ /** Unwire every registry client. A missing or already-clean config is a no-op. */
967
+ export function unwireAllClients() {
968
+ return ALL_CLIENTS.map((client) => client.unwire());
969
+ }
@@ -21,9 +21,13 @@
21
21
  * resolved `--admin-pass` flag) or `explicitKeyPath` (e.g. `--key`,
22
22
  * paired with `agentId`). Always wins; this is the operator saying
23
23
  * "use exactly this."
24
+ * 1.5 A FLAG-PINNED agent (flair#1500) — `agentIdSource === "flag"`
25
+ * (`--agent X`). Signs as X BEFORE the env tier, and hard-errors if X
26
+ * has no key. An explicitly named identity is never quietly replaced.
24
27
  * 2. ENV — `FLAIR_TOKEN` (Bearer) or `FLAIR_ADMIN_PASS` / `HDB_ADMIN_
25
28
  * PASSWORD` (Basic admin auth). Ambient but still an explicit
26
- * operator/CI choice.
29
+ * operator/CI choice. (An ENV-pinned identity — `FLAIR_AGENT_ID` — stays
30
+ * BELOW this tier; see the never-silent notice below.)
27
31
  * 3. A PINNED agent identity — `agentId` was already known (an --agent
28
32
  * flag, FLAIR_AGENT_ID env, or an id the caller extracted from its own
29
33
  * request body/query string) — signed with THAT agent's key via the
@@ -49,6 +53,14 @@
49
53
  * more specific failure; guessing at unrelated keys on disk in that case
50
54
  * would obscure the real error instead of explaining it (the flair#741 fix
51
55
  * #3 lesson, applied here — see `sendJsonRequest`'s 403 branch).
56
+ *
57
+ * ── Named identity vs ambient credential: never a silent substitution ────
58
+ * Precedence above is deliberately unchanged (flair#1504/#1507), but a caller
59
+ * that NAMED an identity (`--agent` or `FLAIR_AGENT_ID`) and got a different,
60
+ * usually more-privileged, credential is always told so on stderr — see
61
+ * `describeIdentityOverride`/`emitIdentityOverrideNotice` below. A named
62
+ * identity is never quietly upgraded to admin, and a two-explicit-flags
63
+ * conflict (`--admin-pass` + `--agent`) is never quiet either.
52
64
  */
53
65
  import { existsSync, readFileSync, readdirSync, statSync, } from "node:fs";
54
66
  import { homedir } from "node:os";
@@ -419,6 +431,44 @@ export async function tryAgentKeyFloor(baseUrl, method, path, body, keysDir) {
419
431
  }
420
432
  return undefined;
421
433
  }
434
+ /**
435
+ * One actionable line for a named identity that was not the signer, or `null`
436
+ * when the named identity itself signed (the normal, expected case). Pure and
437
+ * exported so the matrix is unit-testable without spawning the CLI.
438
+ */
439
+ export function describeIdentityOverride(input) {
440
+ const { agentId, namedSource, credentialSource, adminPassEnvVar } = input;
441
+ const named = namedSource === "flag" ? `--agent '${agentId}'` : `FLAIR_AGENT_ID='${agentId}'`;
442
+ switch (credentialSource) {
443
+ case "flag-agent":
444
+ case "pinned-agent":
445
+ case "explicit-key":
446
+ // The named identity signed — nothing was overridden.
447
+ return null;
448
+ case "explicit-admin":
449
+ return `Warning: ${named} names an identity, but --admin-pass was also given; signing as admin. Drop --admin-pass to sign as '${agentId}'.`;
450
+ case "env-token":
451
+ return `Warning: ${named} names an identity, but FLAIR_TOKEN is set; signing with that bearer token instead. Unset FLAIR_TOKEN to sign as '${agentId}'.`;
452
+ case "env-admin":
453
+ return `Warning: ${named} names an identity, but an ambient admin password (${adminPassEnvVar ?? "FLAIR_ADMIN_PASS"}) is set; signing as admin. Unset it to sign as '${agentId}'.`;
454
+ case "local-admin-file":
455
+ return `Warning: ${named} names an identity, but no key was found for it and the local ~/.flair/admin-pass file is present; signing as admin. Add a key for '${agentId}' to sign as it.`;
456
+ }
457
+ }
458
+ // De-duplicate in one process (a command may issue several requests) so an
459
+ // operator sees the notice once, not once per call. Tests reset it explicitly.
460
+ const emittedIdentityOverrides = new Set();
461
+ /** Write an override notice to stderr once per process. Injectable for tests. */
462
+ export function emitIdentityOverrideNotice(message, write = (s) => { process.stderr.write(s); }) {
463
+ if (emittedIdentityOverrides.has(message))
464
+ return;
465
+ emittedIdentityOverrides.add(message);
466
+ write(message + "\n");
467
+ }
468
+ /** Test hook: forget notices already emitted in this process. */
469
+ export function resetIdentityOverrideNotices() {
470
+ emittedIdentityOverrides.clear();
471
+ }
422
472
  /**
423
473
  * The shared resolver: given a Flair REST call and whatever credential
424
474
  * material the caller already knows about, resolve auth across all 5 tiers
@@ -432,13 +482,18 @@ export async function authedRequest(method, path, body, opts) {
432
482
  // (flair#1345 — these three sites used to hardcode the literal `admin`).
433
483
  const adminUser = resolveAdminUser(opts.adminUser);
434
484
  let authHeader;
485
+ // Which tier supplied authHeader — drives the never-silent override notice
486
+ // below (see describeIdentityOverride).
487
+ let credSource;
435
488
  // Tier 1: explicit — caller-resolved flag material always wins.
436
489
  if (opts.explicitAdminPass) {
437
490
  authHeader = `Basic ${Buffer.from(`${adminUser}:${opts.explicitAdminPass}`).toString("base64")}`;
491
+ credSource = "explicit-admin";
438
492
  }
439
493
  else if (opts.explicitKeyPath && opts.agentId) {
440
494
  try {
441
495
  authHeader = buildEd25519Auth(opts.agentId, method, path, opts.explicitKeyPath);
496
+ credSource = "explicit-key";
442
497
  }
443
498
  catch (err) {
444
499
  const message = err instanceof Error ? err.message : String(err);
@@ -459,15 +514,18 @@ export async function authedRequest(method, path, body, opts) {
459
514
  `or the local admin-pass file.`);
460
515
  }
461
516
  authHeader = buildEd25519Auth(opts.agentId, method, path, keyPath);
517
+ credSource = "flag-agent";
462
518
  }
463
519
  // Tier 2: env — FLAIR_TOKEN (Bearer), else FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD (Basic).
464
520
  if (!authHeader) {
465
521
  if (process.env.FLAIR_TOKEN) {
466
522
  authHeader = `Bearer ${process.env.FLAIR_TOKEN}`;
523
+ credSource = "env-token";
467
524
  }
468
525
  else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
469
526
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
470
527
  authHeader = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
528
+ credSource = "env-admin";
471
529
  }
472
530
  }
473
531
  // Tier 3: a PINNED agent identity — sign specifically as this agent via
@@ -478,6 +536,7 @@ export async function authedRequest(method, path, body, opts) {
478
536
  if (keyPath) {
479
537
  try {
480
538
  authHeader = buildEd25519Auth(opts.agentId, method, path, keyPath);
539
+ credSource = "pinned-agent";
481
540
  }
482
541
  catch (err) {
483
542
  const message = err instanceof Error ? err.message : String(err);
@@ -491,6 +550,7 @@ export async function authedRequest(method, path, body, opts) {
491
550
  const filePass = resolveLocalAdminPass(undefined, !isLocal);
492
551
  if (filePass) {
493
552
  authHeader = `Basic ${Buffer.from(`${adminUser}:${filePass}`).toString("base64")}`;
553
+ credSource = "local-admin-file";
494
554
  }
495
555
  }
496
556
  catch (err) {
@@ -500,6 +560,21 @@ export async function authedRequest(method, path, body, opts) {
500
560
  console.error(`Warning: ~/.flair/admin-pass unusable: ${message}`);
501
561
  }
502
562
  }
563
+ // Never let a named identity (--agent flag or FLAIR_AGENT_ID env) be
564
+ // silently outranked by a different credential. Precedence is unchanged
565
+ // (see the section above describeIdentityOverride); only the silence is.
566
+ if (opts.agentId && (opts.agentIdSource === "flag" || opts.agentIdSource === "env") && credSource) {
567
+ const notice = describeIdentityOverride({
568
+ agentId: opts.agentId,
569
+ namedSource: opts.agentIdSource,
570
+ credentialSource: credSource,
571
+ adminPassEnvVar: process.env.FLAIR_ADMIN_PASS
572
+ ? "FLAIR_ADMIN_PASS"
573
+ : process.env.HDB_ADMIN_PASSWORD ? "HDB_ADMIN_PASSWORD" : undefined,
574
+ });
575
+ if (notice)
576
+ emitIdentityOverrideNotice(notice);
577
+ }
503
578
  try {
504
579
  return await sendJsonRequest(opts.baseUrl, method, path, body, authHeader, isLocal);
505
580
  }