@tpsdev-ai/flair 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * fabric-upgrade.ts — `flair upgrade --target <fabric-url>`
3
+ *
4
+ * One-command upgrade of a Flair instance already DEPLOYED to a Harper Fabric
5
+ * cluster (as opposed to a local `flair upgrade`, which upgrades the globally
6
+ * installed npm packages on the current host).
7
+ *
8
+ * Why this exists — the manual dance it replaces:
9
+ * Upgrading a deployed Fabric Flair used to require, by hand:
10
+ * 1. mkdir a fresh temp dir
11
+ * 2. write a package.json that depends on @tpsdev-ai/flair@<version> AND
12
+ * carries an `overrides` block pinning @harperfast/harper to a fixed
13
+ * version — because the PUBLISHED flair declares an OLD Harper
14
+ * (@harperfast/harper@5.0.21 as of flair@0.14.0) whose component
15
+ * packager (`packageComponent`) emits an EMPTY tarball when the package
16
+ * root lives under node_modules (the real npm-install scenario).
17
+ * See flair#513 — fixed in Harper >= 5.1.13.
18
+ * 3. npm install
19
+ * 4. run `flair deploy --target <url>` from that temp dir
20
+ * This module bakes the Harper pin in so the whole thing is one command.
21
+ *
22
+ * `deploy()` (src/deploy.ts) is REUSED verbatim for the packaging + spawn of
23
+ * the bundled Harper's `harper deploy` — this module never reimplements deploy.
24
+ * It only: resolves the target version, prepares a clean temp deployable with
25
+ * the Harper override baked in, confirms the resolved Harper bin is the fix
26
+ * version, then hands the temp package root to deploy() via `packageRoot`.
27
+ */
28
+ import { spawnSync } from "node:child_process";
29
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { tmpdir } from "node:os";
32
+ import { createRequire } from "node:module";
33
+ /**
34
+ * Minimum @harperfast/harper version whose component packager works when the
35
+ * package root is under node_modules (flair#513 — the empty-tarball fix landed
36
+ * in 5.1.13). Anything below this MUST be overridden before a Fabric deploy.
37
+ */
38
+ export const MIN_HARPER_VERSION = "5.1.13";
39
+ /**
40
+ * Version we pin Harper to when an override is needed and `--harper-version`
41
+ * isn't given and we can't resolve the registry's latest. Known-good, ships the
42
+ * packageComponent fix. 5.1.14 is the latest published as of this writing.
43
+ */
44
+ export const DEFAULT_HARPER_PIN = "5.1.14";
45
+ const FLAIR_PKG = "@tpsdev-ai/flair";
46
+ const HARPER_PKG = "@harperfast/harper";
47
+ /**
48
+ * Parse "5.1.14" / "5.1.14-rc.1" / "v0.14.0" into [major, minor, patch],
49
+ * ignoring any pre-release / build suffix for comparison. Returns null when the
50
+ * core can't be parsed as three numeric segments.
51
+ */
52
+ export function parseSemverCore(v) {
53
+ if (!v)
54
+ return null;
55
+ const core = v.trim().replace(/^v/, "").split("-")[0].split("+")[0];
56
+ const parts = core.split(".");
57
+ if (parts.length < 3)
58
+ return null;
59
+ const nums = parts.slice(0, 3).map((p) => Number(p));
60
+ if (nums.some((n) => !Number.isFinite(n)))
61
+ return null;
62
+ return [nums[0], nums[1], nums[2]];
63
+ }
64
+ /** True when `a` >= `b` by numeric major.minor.patch (pre-release ignored). */
65
+ export function semverGte(a, b) {
66
+ const pa = parseSemverCore(a);
67
+ const pb = parseSemverCore(b);
68
+ if (!pa || !pb)
69
+ return false;
70
+ for (let i = 0; i < 3; i++) {
71
+ if (pa[i] > pb[i])
72
+ return true;
73
+ if (pa[i] < pb[i])
74
+ return false;
75
+ }
76
+ return true; // equal
77
+ }
78
+ /**
79
+ * Decide what (if any) @harperfast/harper override to bake into the temp
80
+ * deployable's package.json.
81
+ *
82
+ * - If flair already declares Harper >= MIN_HARPER_VERSION → no override
83
+ * needed; keep what it ships (pin = declared).
84
+ * - Otherwise (declared is older, absent, or unparseable) → override to
85
+ * `preferredPin` (caller passes --harper-version, else registry latest,
86
+ * else DEFAULT_HARPER_PIN). We still require the chosen pin to satisfy
87
+ * MIN_HARPER_VERSION, falling back to DEFAULT_HARPER_PIN if the caller
88
+ * passed something too old.
89
+ *
90
+ * Pure — no I/O. The LOAD-BEARING bit is `overridden`: when true, the temp
91
+ * package.json gets `overrides: { "@harperfast/harper": pin }`.
92
+ */
93
+ export function resolveHarperPin(declared, preferredPin) {
94
+ if (declared && semverGte(declared, MIN_HARPER_VERSION)) {
95
+ return {
96
+ pin: declared,
97
+ overridden: false,
98
+ declared,
99
+ reason: `flair declares ${HARPER_PKG}@${declared} (>= ${MIN_HARPER_VERSION}); no override needed`,
100
+ };
101
+ }
102
+ // Need an override. Choose the pin: prefer the caller's, but never below the
103
+ // fix floor — a stale --harper-version would silently reintroduce flair#513.
104
+ let pin = preferredPin && semverGte(preferredPin, MIN_HARPER_VERSION)
105
+ ? preferredPin
106
+ : DEFAULT_HARPER_PIN;
107
+ const declaredLabel = declared ? `${declared}` : "none";
108
+ const tooOldHint = preferredPin && !semverGte(preferredPin, MIN_HARPER_VERSION)
109
+ ? ` (requested ${preferredPin} is below the ${MIN_HARPER_VERSION} fix floor — using ${pin} instead)`
110
+ : "";
111
+ return {
112
+ pin,
113
+ overridden: true,
114
+ declared,
115
+ reason: `flair declares ${HARPER_PKG}@${declaredLabel} (< ${MIN_HARPER_VERSION}, flair#513 empty-tarball) — pinning to ${pin}${tooOldHint}`,
116
+ };
117
+ }
118
+ /**
119
+ * Build the package.json contents for the temp deployable. Depends on
120
+ * @tpsdev-ai/flair@<version> and, when the Harper pin is an override, carries
121
+ * the `overrides` block that forces npm to install the fix Harper under the
122
+ * flair package's node_modules.
123
+ */
124
+ export function buildDeployablePackageJson(flairVersion, pin) {
125
+ const pkg = {
126
+ name: "flair-fabric-upgrade-stage",
127
+ version: "0.0.0",
128
+ private: true,
129
+ dependencies: {
130
+ [FLAIR_PKG]: flairVersion,
131
+ },
132
+ };
133
+ if (pin.overridden) {
134
+ pkg.overrides = { [HARPER_PKG]: pin.pin };
135
+ }
136
+ return pkg;
137
+ }
138
+ // ─── Default (real) dependency implementations ──────────────────────────────
139
+ const REGISTRY = "https://registry.npmjs.org";
140
+ async function defaultFetchLatestFlairVersion() {
141
+ const res = await fetch(`${REGISTRY}/${FLAIR_PKG}/latest`, {
142
+ signal: AbortSignal.timeout(10_000),
143
+ });
144
+ if (!res.ok) {
145
+ throw new Error(`npm registry returned ${res.status} for ${FLAIR_PKG}/latest`);
146
+ }
147
+ const data = (await res.json());
148
+ if (!data.version)
149
+ throw new Error(`No version in registry response for ${FLAIR_PKG}`);
150
+ return data.version;
151
+ }
152
+ async function defaultFetchDeclaredHarperVersion(flairVersion) {
153
+ const res = await fetch(`${REGISTRY}/${FLAIR_PKG}/${flairVersion}`, {
154
+ signal: AbortSignal.timeout(10_000),
155
+ });
156
+ if (!res.ok)
157
+ return null;
158
+ const data = (await res.json());
159
+ return data.dependencies?.[HARPER_PKG] ?? null;
160
+ }
161
+ function defaultNpmInstall(dir) {
162
+ // No package spec on argv — npm reads dependencies + overrides from the
163
+ // package.json we wrote into `dir`. --omit=dev keeps the stage lean;
164
+ // --no-audit/--no-fund cut noise. Output streamed for operator visibility.
165
+ const r = spawnSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], { cwd: dir, stdio: "inherit" });
166
+ if (r.error)
167
+ throw r.error;
168
+ if (r.status !== 0) {
169
+ throw new Error(`npm install failed in staging dir (exit ${r.status})`);
170
+ }
171
+ }
172
+ /**
173
+ * Query the Fabric for the deployed Flair component version. Best-effort: the
174
+ * public REST surface exposes `/Health`, which echoes the running Flair version
175
+ * in newer builds; if the shape isn't recognized we return null (verification
176
+ * downgrades to a soft notice, never a hard failure).
177
+ */
178
+ async function defaultFetchDeployedVersion(opts) {
179
+ const base = opts.url.replace(/\/$/, "");
180
+ const headers = {};
181
+ if (opts.fabricUser && opts.fabricPassword) {
182
+ const auth = Buffer.from(`${opts.fabricUser}:${opts.fabricPassword}`).toString("base64");
183
+ headers.Authorization = `Basic ${auth}`;
184
+ }
185
+ try {
186
+ const res = await fetch(`${base}/Health`, {
187
+ headers,
188
+ signal: AbortSignal.timeout(10_000),
189
+ });
190
+ if (!res.ok)
191
+ return null;
192
+ const text = await res.text();
193
+ let body;
194
+ try {
195
+ body = JSON.parse(text);
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ // Tolerate a few shapes: { version }, { flair: { version } }, { flairVersion }.
201
+ const v = body?.version ?? body?.flairVersion ?? body?.flair?.version ?? null;
202
+ return typeof v === "string" ? v : null;
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
208
+ /** Resolve the @harperfast/harper bin that npm installed under the staged flair. */
209
+ export function resolveStagedHarperVersion(stagingDir) {
210
+ const harperPkgJson = join(stagingDir, "node_modules", FLAIR_PKG, "node_modules", HARPER_PKG, "package.json");
211
+ // npm dedupes: with an override the fixed Harper may hoist to the staging
212
+ // root rather than nest under flair. Check both.
213
+ const hoisted = join(stagingDir, "node_modules", HARPER_PKG, "package.json");
214
+ for (const candidate of [harperPkgJson, hoisted]) {
215
+ if (existsSync(candidate)) {
216
+ try {
217
+ const pkg = JSON.parse(readFileSync(candidate, "utf8"));
218
+ if (typeof pkg.version === "string")
219
+ return pkg.version;
220
+ }
221
+ catch {
222
+ /* try next */
223
+ }
224
+ }
225
+ }
226
+ // Last resort: resolve through the staged flair's module graph.
227
+ try {
228
+ const flairPkg = join(stagingDir, "node_modules", FLAIR_PKG, "package.json");
229
+ const req = createRequire(flairPkg);
230
+ const resolved = req.resolve(`${HARPER_PKG}/package.json`);
231
+ const pkg = JSON.parse(readFileSync(resolved, "utf8"));
232
+ return typeof pkg.version === "string" ? pkg.version : null;
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ /** Locate the staged @tpsdev-ai/flair package root (the deploy() packageRoot). */
239
+ export function resolveStagedFlairRoot(stagingDir) {
240
+ const root = join(stagingDir, "node_modules", FLAIR_PKG);
241
+ if (!existsSync(join(root, "package.json"))) {
242
+ throw new Error(`Staged flair not found at ${root} — npm install may have failed`);
243
+ }
244
+ return root;
245
+ }
246
+ function defaultDeps() {
247
+ // deploy is imported lazily so unit tests can fully mock without pulling the
248
+ // real Harper-spawning module into their graph.
249
+ return {
250
+ fetchLatestFlairVersion: defaultFetchLatestFlairVersion,
251
+ fetchDeclaredHarperVersion: defaultFetchDeclaredHarperVersion,
252
+ npmInstall: defaultNpmInstall,
253
+ fetchDeployedVersion: defaultFetchDeployedVersion,
254
+ deploy: async (opts) => {
255
+ const { deploy } = await import("./deploy.js");
256
+ return deploy(opts);
257
+ },
258
+ log: (m) => console.log(m),
259
+ };
260
+ }
261
+ // ─── Orchestrator ───────────────────────────────────────────────────────────
262
+ /**
263
+ * Build the upgrade plan (steps 1 + version diff). Pure-ish: only the two
264
+ * read-only registry lookups, no install, no deploy. `--check` stops here.
265
+ */
266
+ export async function planFabricUpgrade(opts, deps) {
267
+ const project = opts.project ?? "flair";
268
+ const targetVersion = opts.version ?? (await deps.fetchLatestFlairVersion());
269
+ const declaredHarper = await deps.fetchDeclaredHarperVersion(targetVersion);
270
+ const harper = resolveHarperPin(declaredHarper, opts.harperVersion);
271
+ const currentVersion = await deps.fetchDeployedVersion({
272
+ url: opts.target,
273
+ project,
274
+ fabricUser: opts.fabricUser,
275
+ fabricPassword: opts.fabricPassword,
276
+ });
277
+ const upToDate = currentVersion != null &&
278
+ parseSemverCore(currentVersion) != null &&
279
+ parseSemverCore(targetVersion) != null &&
280
+ semverGte(currentVersion, targetVersion) &&
281
+ semverGte(targetVersion, currentVersion);
282
+ return { target: opts.target, project, targetVersion, currentVersion, harper, upToDate };
283
+ }
284
+ /**
285
+ * Full Fabric upgrade. Reuses deploy() for the packaging/deploy — this function
286
+ * only prepares the clean temp deployable (with the Harper override baked in)
287
+ * and verifies afterward.
288
+ *
289
+ * SAFETY: never logs creds; always cleans up the staging dir (finally); never
290
+ * touches the running local Flair — all work happens in an isolated temp dir
291
+ * and the deploy targets the remote Fabric URL only.
292
+ */
293
+ export async function fabricUpgrade(opts, injected) {
294
+ const deps = { ...defaultDeps(), ...injected };
295
+ const log = deps.log ?? (() => { });
296
+ // Step 1 + diff.
297
+ const plan = await planFabricUpgrade(opts, deps);
298
+ log(`Fabric: ${plan.target}`);
299
+ log(`Project: ${plan.project}`);
300
+ log(`Version: ${plan.currentVersion ?? "(unknown)"} → ${plan.targetVersion}` +
301
+ (plan.upToDate ? " (already up to date)" : ""));
302
+ log(`Harper: ${plan.harper.reason}`);
303
+ if (opts.check) {
304
+ log("");
305
+ log("--check: plan only — not deploying.");
306
+ return { plan, deployed: false, verifiedVersion: null, stagingDir: "" };
307
+ }
308
+ // Step 2: prepare a clean deployable in an isolated temp dir.
309
+ const stagingDir = mkdtempSync(join(tmpdir(), "flair-fabric-upgrade-"));
310
+ try {
311
+ const pkgJson = buildDeployablePackageJson(plan.targetVersion, plan.harper);
312
+ writeFileSync(join(stagingDir, "package.json"), JSON.stringify(pkgJson, null, 2));
313
+ log(`\nStaging ${FLAIR_PKG}@${plan.targetVersion} in ${stagingDir} ...`);
314
+ deps.npmInstall(stagingDir);
315
+ // CONFIRM the resolved Harper bin is the fix version before deploying.
316
+ const stagedHarper = resolveStagedHarperVersion(stagingDir);
317
+ if (!stagedHarper) {
318
+ throw new Error(`Could not resolve the staged ${HARPER_PKG} version — refusing to deploy ` +
319
+ `(packageComponent may emit an empty tarball, flair#513).`);
320
+ }
321
+ if (!semverGte(stagedHarper, MIN_HARPER_VERSION)) {
322
+ throw new Error(`Staged ${HARPER_PKG}@${stagedHarper} is below the ${MIN_HARPER_VERSION} ` +
323
+ `fix floor — the override did not take. Refusing to deploy (flair#513).`);
324
+ }
325
+ log(`✓ Staged ${HARPER_PKG}@${stagedHarper} (>= ${MIN_HARPER_VERSION})`);
326
+ const packageRoot = resolveStagedFlairRoot(stagingDir);
327
+ // Step 3: REUSE deploy() — do not reimplement packaging/spawn.
328
+ log(`Deploying ${plan.project} ${plan.targetVersion} to ${plan.target} ...`);
329
+ const result = await deps.deploy({
330
+ target: opts.target,
331
+ project: plan.project,
332
+ version: plan.targetVersion,
333
+ fabricUser: opts.fabricUser,
334
+ fabricPassword: opts.fabricPassword,
335
+ restart: opts.restart,
336
+ replicated: opts.replicated,
337
+ packageRoot,
338
+ });
339
+ // Step 4: verify the deployed version (best-effort).
340
+ let verifiedVersion = null;
341
+ try {
342
+ verifiedVersion = await deps.fetchDeployedVersion({
343
+ url: opts.target,
344
+ project: plan.project,
345
+ fabricUser: opts.fabricUser,
346
+ fabricPassword: opts.fabricPassword,
347
+ });
348
+ }
349
+ catch {
350
+ verifiedVersion = null;
351
+ }
352
+ if (verifiedVersion) {
353
+ const ok = semverGte(verifiedVersion, plan.targetVersion);
354
+ log(ok
355
+ ? `✓ Fabric now reports Flair ${verifiedVersion}`
356
+ : `⚠ Fabric reports ${verifiedVersion} (expected ${plan.targetVersion}) — may still be restarting`);
357
+ }
358
+ else {
359
+ log(`✓ Deployed ${result.version} (Fabric did not report a version to verify against)`);
360
+ }
361
+ return { plan, deployed: true, verifiedVersion, stagingDir };
362
+ }
363
+ finally {
364
+ // SAFETY: always clean up the temp dir.
365
+ rmSync(stagingDir, { recursive: true, force: true });
366
+ }
367
+ }
@@ -5,9 +5,28 @@
5
5
  // - detect(): boolean - returns true if client is installed
6
6
  // - wire(options: { agentId: string; flairUrl: string }): { ok: boolean; message: string }
7
7
  //
8
+ // Wiring contract (FIX 4 — onboarding dogfood round 1):
9
+ // "wired" MUST mean a config file was actually written. A wire function returns
10
+ // { ok: true } ONLY when it merged the Flair MCP server into the client's real
11
+ // config file. If it cannot (unknown path, write error), it returns
12
+ // { ok: false } with the correct per-OS snippet to paste — never a vague
13
+ // "manual wiring required" while elsewhere the run claims the client is wired.
14
+ // All paths are resolved cross-platform (Linux included) via standard
15
+ // per-client locations under $HOME / $XDG_CONFIG_HOME.
8
16
  // ---- Detection helpers ----------------------------------------------------------
9
17
  import { spawnSync } from "node:child_process";
10
- import { accessSync, constants } from "node:fs";
18
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+ /**
22
+ * Resolve the user's home dir. Prefer the live HOME/USERPROFILE env over
23
+ * os.homedir(), which caches the value at process start and so ignores a
24
+ * runtime HOME override — same convention as src/cli.ts ("so tests can
25
+ * override"). Production behavior is unchanged (HOME is set on every OS).
26
+ */
27
+ function resolveHome() {
28
+ return process.env.HOME || process.env.USERPROFILE || homedir();
29
+ }
11
30
  /**
12
31
  * Check if a command exists in PATH (cross-platform alternative to `which`).
13
32
  * Does not spawn a child process — pure filesystem check.
@@ -85,94 +104,126 @@ function cursorDetect() {
85
104
  return false;
86
105
  }
87
106
  }
88
- // ---- Internal wiring functions --------------------------------------------------
89
- function _wireClaudeCode(env) {
107
+ // ---- Shared config shapes -------------------------------------------------------
108
+ /** The standard MCP stdio server entry every client (except Codex TOML) uses. */
109
+ function flairMcpEntry(env) {
90
110
  return {
91
- ok: false,
92
- message: `Manual wiring required for Claude Code:\n` +
93
- `1. Locate Claude Code's settings file (usually ~/Library/Application Support/Claude/settings.json on macOS or %APPDATA%/Claude/settings.json on Windows)\n` +
94
- `2. Add or update the "mcpServers" section:\n` +
95
- ` {\n` +
96
- ` "mcpServers": {\n` +
97
- ` "flair": {\n` +
98
- ` "command": "npx",\n` +
99
- ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
100
- ` "env": {\n` +
101
- ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
102
- ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
103
- ` }\n` +
104
- ` }\n` +
105
- ` }\n` +
106
- ` }\n` +
107
- `3. Restart Claude Code\n` +
108
- `Note: This is a manual step - the Flair CLI cannot automatically modify Claude Code's settings due to security restrictions.`,
111
+ command: "npx",
112
+ args: ["-y", "@tpsdev-ai/flair-mcp"],
113
+ env: { FLAIR_AGENT_ID: env.FLAIR_AGENT_ID, FLAIR_URL: env.FLAIR_URL },
109
114
  };
110
115
  }
116
+ /** Pretty-printed JSON `mcpServers.flair` snippet for copy-paste fallbacks. */
117
+ function jsonSnippet(env) {
118
+ return JSON.stringify({ mcpServers: { flair: flairMcpEntry(env) } }, null, 2);
119
+ }
120
+ /** TOML `[mcp_servers.flair]` snippet (Codex format). */
121
+ function tomlSnippet(env) {
122
+ return [
123
+ `[mcp_servers.flair]`,
124
+ `command = "npx"`,
125
+ `args = ["-y", "@tpsdev-ai/flair-mcp"]`,
126
+ ``,
127
+ `[mcp_servers.flair.env]`,
128
+ `FLAIR_AGENT_ID = "${env.FLAIR_AGENT_ID}"`,
129
+ `FLAIR_URL = "${env.FLAIR_URL}"`,
130
+ ].join("\n");
131
+ }
132
+ /**
133
+ * Merge the Flair MCP server into a JSON config file with an `mcpServers` map.
134
+ * Creates the file (and parent dir) if absent; preserves existing servers and
135
+ * any other top-level keys. Returns ok:true only when the file was written.
136
+ */
137
+ function wireJsonMcp(configPath, label, env) {
138
+ const home = resolveHome();
139
+ const display = configPath.startsWith(home) ? "~" + configPath.slice(home.length) : configPath;
140
+ try {
141
+ let config = {};
142
+ if (existsSync(configPath)) {
143
+ const raw = readFileSync(configPath, "utf-8").trim();
144
+ if (raw)
145
+ config = JSON.parse(raw);
146
+ }
147
+ config.mcpServers = config.mcpServers || {};
148
+ const existing = config.mcpServers.flair;
149
+ if (existing && existing.env?.FLAIR_URL === env.FLAIR_URL && existing.env?.FLAIR_AGENT_ID === env.FLAIR_AGENT_ID) {
150
+ return { ok: true, message: `${label}: already wired in ${display}` };
151
+ }
152
+ config.mcpServers.flair = flairMcpEntry(env);
153
+ mkdirSync(dirname(configPath), { recursive: true });
154
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
155
+ return { ok: true, message: `${label}: wired ${display} (restart ${label} to pick it up)` };
156
+ }
157
+ catch (err) {
158
+ const reason = err instanceof Error ? err.message : String(err);
159
+ return {
160
+ ok: false,
161
+ message: `${label}: manual wiring needed (could not write ${display}: ${reason}).\n` +
162
+ ` Add this to ${display}:\n${indent(jsonSnippet(env))}`,
163
+ };
164
+ }
165
+ }
166
+ function indent(s) {
167
+ return s.split("\n").map((l) => ` ${l}`).join("\n");
168
+ }
169
+ // ---- Per-client config paths (cross-platform, Linux included) --------------------
170
+ /** Cursor: ~/.cursor/mcp.json on every OS. */
171
+ function cursorConfigPath() {
172
+ return join(resolveHome(), ".cursor", "mcp.json");
173
+ }
174
+ /** Gemini CLI: ~/.gemini/settings.json on every OS. */
175
+ function geminiConfigPath() {
176
+ return join(resolveHome(), ".gemini", "settings.json");
177
+ }
178
+ /** Codex CLI: ~/.codex/config.toml on every OS. */
179
+ function codexConfigPath() {
180
+ return join(resolveHome(), ".codex", "config.toml");
181
+ }
182
+ // ---- Internal wiring functions --------------------------------------------------
183
+ //
184
+ // Claude Code wiring lives inline in src/cli.ts (it writes ~/.claude.json, the
185
+ // one client the CLI safely edits, cross-platform). _wireClaudeCode here is the
186
+ // fallback used when something calls the array form; it returns the snippet for
187
+ // ~/.claude.json so the message is unambiguous and correct on every OS.
188
+ function _wireClaudeCode(env) {
189
+ // The real auto-wire is inline in cli.ts. If reached via the array, point at
190
+ // the correct cross-platform path (~/.claude.json — same on macOS/Linux/Win)
191
+ // and give the exact snippet. Never emit macOS-only paths here.
192
+ return wireJsonMcp(join(resolveHome(), ".claude.json"), "Claude Code", env);
193
+ }
111
194
  function _wireCodex(env) {
112
- return {
113
- ok: false,
114
- message: `Manual wiring required for Codex:\n` +
115
- `1. Locate Codex's configuration (check ~/.codex/config or similar)\n` +
116
- `2. Add the Flair MCP server configuration:\n` +
117
- ` {\n` +
118
- ` "mcpServers": {\n` +
119
- ` "flair": {\n` +
120
- ` "command": "npx",\n` +
121
- ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
122
- ` "env": {\n` +
123
- ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
124
- ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
125
- ` }\n` +
126
- ` }\n` +
127
- ` }\n` +
128
- ` }\n` +
129
- `3. Restart Codex\n` +
130
- `Note: This is a manual step - the Flair CLI cannot automatically modify Codex's configuration due to security restrictions.`,
131
- };
195
+ // Codex uses TOML with a [mcp_servers.flair] table. We don't carry a TOML
196
+ // parser, and blind text-appending risks corrupting/duplicating an existing
197
+ // table so we only auto-write when the file does NOT yet exist (clean
198
+ // create), otherwise emit the exact TOML block to paste.
199
+ const path = codexConfigPath();
200
+ const display = "~/.codex/config.toml";
201
+ try {
202
+ if (existsSync(path)) {
203
+ return {
204
+ ok: false,
205
+ message: `Codex: manual wiring needed — ${display} already exists.\n` +
206
+ ` Add this block to ${display}:\n${indent(tomlSnippet(env))}`,
207
+ };
208
+ }
209
+ mkdirSync(dirname(path), { recursive: true });
210
+ writeFileSync(path, tomlSnippet(env) + "\n");
211
+ return { ok: true, message: `Codex: wired ${display} (restart Codex to pick it up)` };
212
+ }
213
+ catch (err) {
214
+ const reason = err instanceof Error ? err.message : String(err);
215
+ return {
216
+ ok: false,
217
+ message: `Codex: manual wiring needed (could not write ${display}: ${reason}).\n` +
218
+ ` Add this block to ${display}:\n${indent(tomlSnippet(env))}`,
219
+ };
220
+ }
132
221
  }
133
222
  function _wireGemini(env) {
134
- return {
135
- ok: false,
136
- message: `Manual wiring required for Gemini:\n` +
137
- `1. Locate Gemini's configuration (check ~/.gemini/config or similar)\n` +
138
- `2. Add the Flair MCP server configuration:\n` +
139
- ` {\n` +
140
- ` "mcpServers": {\n` +
141
- ` "flair": {\n` +
142
- ` "command": "npx",\n` +
143
- ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
144
- ` "env": {\n` +
145
- ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
146
- ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
147
- ` }\n` +
148
- ` }\n` +
149
- ` }\n` +
150
- ` }\n` +
151
- `3. Restart Gemini\n` +
152
- `Note: This is a manual step - the Flair CLI cannot automatically modify Gemini's configuration due to security restrictions.`,
153
- };
223
+ return wireJsonMcp(geminiConfigPath(), "Gemini", env);
154
224
  }
155
225
  function _wireCursor(env) {
156
- return {
157
- ok: false,
158
- message: `Manual wiring required for Cursor:\n` +
159
- `1. Locate Cursor's settings file (usually ~/.cursor/settings.json)\n` +
160
- `2. Add or update the "mcpServers" section:\n` +
161
- ` {\n` +
162
- ` "mcpServers": {\n` +
163
- ` "flair": {\n` +
164
- ` "command": "npx",\n` +
165
- ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
166
- ` "env": {\n` +
167
- ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
168
- ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
169
- ` }\n` +
170
- ` }\n` +
171
- ` }\n` +
172
- ` }\n` +
173
- `3. Restart Cursor\n` +
174
- `Note: This is a manual step - the Flair CLI cannot automatically modify Cursor's settings due to security restrictions.`,
175
- };
226
+ return wireJsonMcp(cursorConfigPath(), "Cursor", env);
176
227
  }
177
228
  // ---- Exported detection & wiring array ------------------------------------------
178
229
  export const ALL_CLIENTS = [
@@ -274,7 +274,10 @@ export class A2AAdapter extends Resource {
274
274
  // (GET /a2a passes through; POST /a2a must carry TPS-Ed25519 or admin
275
275
  // Basic). The post() handler below additionally enforces sender ==
276
276
  // params.agentId for message/send so an authenticated caller can only
277
- // act AS themselves, not impersonate another agent.
277
+ // act AS themselves, not impersonate another agent. message/send routes
278
+ // a DIRECTED handoff to params.toAgentId (the recipient) by setting the
279
+ // OrgEvent's targetIds = [toAgentId]; OrgEventCatchup filters on that, so
280
+ // the recipient — not the sender — receives the message.
278
281
  allowRead() { return true; }
279
282
  allowCreate() { return true; }
280
283
  async get() {
@@ -439,22 +442,45 @@ export class A2AAdapter extends Resource {
439
442
  // Without this check, an authenticated agent could forge OrgEvents
440
443
  // attributed to any other agent — defeats the whole signed-envelopes
441
444
  // model that delegationChain enforces for TPS mail.
445
+ // NOTE: agentId is the SENDER. The recipient is params.toAgentId.
442
446
  if (!callerIsAdmin && callerAgent !== agentId) {
443
447
  return rpcError(id, -32001, "Forbidden", {
444
448
  detail: `caller ${callerAgent ?? "(anon)"} cannot send as ${agentId}`,
445
449
  });
446
450
  }
447
- const agent = await databases.flair.Agent.get(agentId).catch(() => null);
448
- if (!agent) {
451
+ const sender = await databases.flair.Agent.get(agentId).catch(() => null);
452
+ if (!sender) {
449
453
  return rpcError(id, -32004, "Agent not found", { agentId });
450
454
  }
455
+ // Directed handoff: params.toAgentId (the recipient) controls
456
+ // OrgEvent.targetIds, which is what OrgEventCatchup filters on
457
+ // (`targets.includes(participantId)`). The recipient must exist.
458
+ // Back-compat: if toAgentId is omitted, fall back to the legacy
459
+ // self-scoped broadcast (targetIds = [sender]) so existing callers
460
+ // that only set agentId keep working. The no-spoof guard above is
461
+ // unchanged — toAgentId only affects WHO receives the message, never
462
+ // who it is sent AS, so a sender can hand off to a peer without being
463
+ // able to impersonate anyone.
464
+ const toAgentId = cleanText(params.toAgentId);
465
+ let targetIds;
466
+ if (toAgentId) {
467
+ const recipient = await databases.flair.Agent.get(toAgentId).catch(() => null);
468
+ if (!recipient) {
469
+ return rpcError(id, -32004, "Recipient agent not found", { toAgentId });
470
+ }
471
+ targetIds = [toAgentId];
472
+ }
473
+ else {
474
+ targetIds = [agentId];
475
+ }
451
476
  const summary = truncate(firstTextPart(message) || "A2A message received", 200);
452
477
  await publishOrgEvent({
478
+ authorId: agentId,
453
479
  kind: "a2a.message",
454
480
  scope: agentId,
455
481
  summary,
456
- detail: JSON.stringify({ message }),
457
- targetIds: [agentId],
482
+ detail: JSON.stringify({ message, toAgentId: toAgentId || null }),
483
+ targetIds,
458
484
  });
459
485
  return rpcResult(id, {
460
486
  type: "message",