@tpsdev-ai/flair 0.22.1 → 0.24.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/dist/cli.js +1836 -523
- package/dist/deploy.js +16 -1
- package/dist/doctor-client.js +176 -0
- package/dist/hook-install.js +324 -0
- package/dist/install/clients.js +46 -11
- package/dist/lib/auth-resolve.js +369 -0
- package/dist/lib/mcp-enable.js +787 -0
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +98 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Presence.js +4 -4
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/agent-auth.js +4 -5
- package/dist/resources/auth-middleware.js +4 -4
- package/dist/resources/ed25519-auth.js +37 -0
- package/dist/resources/embeddings-boot.js +38 -1
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +3 -3
- package/schemas/memory.graphql +2 -2
package/dist/deploy.js
CHANGED
|
@@ -225,6 +225,21 @@ export const REPLICATION_FAILURE_RE = /failed to replicate to \d+ (of \d+ )?peer
|
|
|
225
225
|
// the previous stdio:"inherit" passthrough, which gave no way to inspect
|
|
226
226
|
// harper's output. stdin stays "inherit" — harper's deploy never reads
|
|
227
227
|
// from it, so there's nothing to tee there.
|
|
228
|
+
//
|
|
229
|
+
// Resolves on "close", NOT "exit" (flair#699). Node's child_process fires
|
|
230
|
+
// "exit" as soon as the process terminates, but the piped stdout/stderr
|
|
231
|
+
// streams can still have buffered `data` events in flight at that instant —
|
|
232
|
+
// "exit" makes no promise that every chunk already written by the child has
|
|
233
|
+
// been delivered to our listeners yet. "close" is the event Node guarantees
|
|
234
|
+
// fires only after all stdio streams have ended, i.e. every `data` chunk has
|
|
235
|
+
// already been pushed into `chunks`. Resolving on "exit" was a real
|
|
236
|
+
// output-capture race, not just a test artifact: under scheduler pressure
|
|
237
|
+
// (e.g. loaded CI runners) the process could exit and this promise could
|
|
238
|
+
// resolve before the final stderr chunk — often exactly the line carrying
|
|
239
|
+
// the replication-failure signature, since callers naturally console.error
|
|
240
|
+
// their last message immediately before process.exit() — had been received,
|
|
241
|
+
// so REPLICATION_FAILURE_RE silently missed a match it should have made and
|
|
242
|
+
// runHarperDeploy fell through to the generic "exited with code N" error.
|
|
228
243
|
function spawnHarperCaptured(bin, args, cwd, env) {
|
|
229
244
|
return new Promise((resolveP, rejectP) => {
|
|
230
245
|
const p = spawn(process.execPath, [bin, ...args], {
|
|
@@ -242,7 +257,7 @@ function spawnHarperCaptured(bin, args, cwd, env) {
|
|
|
242
257
|
chunks.push(d.toString("utf8"));
|
|
243
258
|
});
|
|
244
259
|
p.on("error", rejectP);
|
|
245
|
-
p.on("
|
|
260
|
+
p.on("close", (code) => resolveP({ code, output: chunks.join("") }));
|
|
246
261
|
});
|
|
247
262
|
}
|
|
248
263
|
function sleep(ms) {
|
package/dist/doctor-client.js
CHANGED
|
@@ -135,6 +135,37 @@ function scanCodexFlairBlock(raw) {
|
|
|
135
135
|
const flairUrl = urlMatch?.[1] || undefined;
|
|
136
136
|
return { present: !!agentId && !!flairUrl, agentId, flairUrl };
|
|
137
137
|
}
|
|
138
|
+
// ── check 2: FLAIR_URL to use when (re-)wiring a client (flair#727) ────────
|
|
139
|
+
/**
|
|
140
|
+
* Pick the FLAIR_URL to feed a wire() call when `doctor --fix` re-wires a
|
|
141
|
+
* client whose block was judged "not present" (readClientMcpBlock — missing
|
|
142
|
+
* FLAIR_AGENT_ID and/or FLAIR_URL). A pre-existing config can still carry a
|
|
143
|
+
* `flairUrl` fragment (e.g. `present:false` because FLAIR_AGENT_ID is empty,
|
|
144
|
+
* but FLAIR_URL scanned fine) — and that fragment can itself be malformed: a
|
|
145
|
+
* bare host with no scheme/port (`"127.0.0.1"`), left over from an older
|
|
146
|
+
* Flair version or a hand-edited config. Blindly reusing it perpetuates the
|
|
147
|
+
* corruption into the freshly suggested block (flair#727 — a real dogfood
|
|
148
|
+
* run printed exactly `FLAIR_URL = "127.0.0.1"`, unusable if pasted).
|
|
149
|
+
*
|
|
150
|
+
* Only trust `existingFlairUrl` when it parses as an absolute http(s) URL;
|
|
151
|
+
* otherwise fall back to `baseUrl` — the live, authoritative URL `doctor`
|
|
152
|
+
* already computed from the same port source as its "Config: ... (port:
|
|
153
|
+
* NNNNN)" line (resolveHttpPort / readPortFromConfig, with live-port
|
|
154
|
+
* discovery layered on top).
|
|
155
|
+
*/
|
|
156
|
+
export function resolveWireFlairUrl(existingFlairUrl, baseUrl) {
|
|
157
|
+
if (existingFlairUrl) {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = new URL(existingFlairUrl);
|
|
160
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:")
|
|
161
|
+
return existingFlairUrl;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Not an absolute URL (e.g. a bare host like "127.0.0.1") — fall through.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return baseUrl;
|
|
168
|
+
}
|
|
138
169
|
/**
|
|
139
170
|
* Pass when EITHER the project-scoped `${cwd}/CLAUDE.md` or the user-level
|
|
140
171
|
* `~/.claude/CLAUDE.md` contains the bootstrap marker — Claude Code loads
|
|
@@ -310,3 +341,148 @@ export function applyOrReportSessionStartHook(homeDir, agentId, skip) {
|
|
|
310
341
|
const fix = fixSessionStartHook(homeDir, agentId);
|
|
311
342
|
return { applied: fix.ok, ok: fix.ok, message: fix.message, hint: fix.ok ? undefined : hint };
|
|
312
343
|
}
|
|
344
|
+
// ── check 5: per-agent iteration for verified-read sections (flair#722) ────
|
|
345
|
+
//
|
|
346
|
+
// `doctor`'s Fleet presence and Migrations sections need a signed (Ed25519)
|
|
347
|
+
// request to reveal server-verified fields (flairVersion/harperVersion,
|
|
348
|
+
// migration state) — previously that meant passing --agent explicitly, even
|
|
349
|
+
// though doctor already enumerates every key in ~/.flair/keys (the "Keys
|
|
350
|
+
// found: N agent(s)" line above). A real dogfood run found the #720
|
|
351
|
+
// halted-migration warning visible via `flair status --agent local` but
|
|
352
|
+
// invisible in the default `doctor` run the same user ran minutes later.
|
|
353
|
+
//
|
|
354
|
+
// These two functions are the pure decision logic for iterating and
|
|
355
|
+
// rendering per agent — no fs, no network, no crypto — so they're
|
|
356
|
+
// unit-testable the same way as the rest of this module. The actual signed
|
|
357
|
+
// fetches (which reuse authFetch/checkAgentRegistered, private to cli.ts)
|
|
358
|
+
// stay in src/cli.ts and call these to decide who to iterate and how a given
|
|
359
|
+
// agent's registration-gate outcome should render.
|
|
360
|
+
/**
|
|
361
|
+
* Decide which agent ids the verified-read sections should iterate over.
|
|
362
|
+
* - `agentFlag` given -> exactly that one id (a plain filter — unchanged
|
|
363
|
+
* pre-#722 semantics: doctor still tries a single signed identity, it
|
|
364
|
+
* just doesn't widen to "every key"). Doesn't require the id to already
|
|
365
|
+
* have a key on disk; the registration gate reports "no local key" for
|
|
366
|
+
* that case rather than silently expanding the search.
|
|
367
|
+
* - no `agentFlag` -> every id in `keyAgentIds` (the ~/.flair/keys
|
|
368
|
+
* enumeration doctor's own "Keys found" check already did), sorted for
|
|
369
|
+
* deterministic, reproducible output across runs.
|
|
370
|
+
*/
|
|
371
|
+
export function planAgentIterations(keyAgentIds, agentFlag) {
|
|
372
|
+
if (agentFlag)
|
|
373
|
+
return [agentFlag];
|
|
374
|
+
return [...keyAgentIds].sort();
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Render decision for one agent's registration-gate outcome, ahead of a
|
|
378
|
+
* verified-read section (Fleet presence / Migrations). Returns null when the
|
|
379
|
+
* agent is registered — the caller should proceed with its actual signed
|
|
380
|
+
* read for that agent. Otherwise returns the finding to print for THAT
|
|
381
|
+
* agent's subsection; the caller must still move on to the next agent
|
|
382
|
+
* (failure isolation, flair#722) rather than aborting the whole section.
|
|
383
|
+
*/
|
|
384
|
+
export function describeAgentGateFinding(agentId, state, detail) {
|
|
385
|
+
switch (state) {
|
|
386
|
+
case "registered":
|
|
387
|
+
return null;
|
|
388
|
+
case "no-key":
|
|
389
|
+
return {
|
|
390
|
+
icon: "warn",
|
|
391
|
+
message: `no local key for '${agentId}' — skipping${detail ? ` (${detail})` : ""}`,
|
|
392
|
+
isIssue: false,
|
|
393
|
+
};
|
|
394
|
+
case "not-registered":
|
|
395
|
+
return {
|
|
396
|
+
icon: "error",
|
|
397
|
+
message: `agent '${agentId}' has a local key but is NOT registered on this Flair instance`,
|
|
398
|
+
// Two ways out, both actionable — register it if it should exist, or
|
|
399
|
+
// (flair#734) clean it up if it's a stale/leftover key. `flair keys
|
|
400
|
+
// prune` never touches a key that IS registered, so it's always a
|
|
401
|
+
// safe suggestion here even when the right fix is actually `agent add`.
|
|
402
|
+
fixHint: `flair agent add ${agentId} (if it should be registered) — or flair keys prune (if it's a stale/leftover key)`,
|
|
403
|
+
isIssue: true,
|
|
404
|
+
};
|
|
405
|
+
case "unreachable":
|
|
406
|
+
return {
|
|
407
|
+
icon: "warn",
|
|
408
|
+
message: `could not verify agent '${agentId}' registration${detail ? ` (${detail})` : ""}`,
|
|
409
|
+
isIssue: false,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// ── check 6: `flair keys prune` classification (flair#734) ─────────────────
|
|
414
|
+
//
|
|
415
|
+
// Follow-up to #731's doctor agent-iteration, which made previously-invisible
|
|
416
|
+
// stale keys visible (each renders as a "not registered" gate finding, check
|
|
417
|
+
// 5 above) but shipped no command to act on it — every doctor run just
|
|
418
|
+
// re-reported the same noise. `flair keys prune` (src/cli.ts) walks the key
|
|
419
|
+
// dir and moves anything it can positively classify as prunable into
|
|
420
|
+
// `<keysDir>/.pruned/<date>/` — never deletes. The network-dependent half
|
|
421
|
+
// (is this agentId actually registered?) reuses checkAgentRegistered
|
|
422
|
+
// (src/cli.ts), the exact same signed GET /Agent/:id check 5's gate uses.
|
|
423
|
+
// This module only owns the PURE decision — given a file's seed-validity and
|
|
424
|
+
// (if checked) registration state, what class is it and why — plus two
|
|
425
|
+
// path/naming helpers pure enough to live here (no crypto, no network).
|
|
426
|
+
/** Name of the archive subdirectory prune moves prunable files into —
|
|
427
|
+
* `<keysDir>/.pruned/<date>/`. Also the one directory name the scanner
|
|
428
|
+
* itself must skip when walking the key dir (never re-classify prune's own
|
|
429
|
+
* archive as a candidate). */
|
|
430
|
+
export const PRUNED_DIR_NAME = ".pruned";
|
|
431
|
+
/** `YYYY-MM-DD`, UTC — the `<date>` component of the archive path. UTC (not
|
|
432
|
+
* local time) so a single prune run always lands in exactly one date
|
|
433
|
+
* bucket regardless of the host's timezone. */
|
|
434
|
+
export function pruneDateStamp(now = new Date()) {
|
|
435
|
+
return now.toISOString().slice(0, 10);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Pick a collision-free destination filename for a move into an archive
|
|
439
|
+
* directory that may already hold a file of the same name (e.g. two prune
|
|
440
|
+
* runs on the same UTC day). Preserves the original name whenever possible;
|
|
441
|
+
* on collision appends `.2`, `.3`, ... until free. Pure — the caller supplies
|
|
442
|
+
* the set of names already present (or about to be present, within the same
|
|
443
|
+
* run) at the destination; no fs access happens here.
|
|
444
|
+
*/
|
|
445
|
+
export function resolveCollisionSafeName(existingNames, filename) {
|
|
446
|
+
const existing = existingNames instanceof Set ? existingNames : new Set(existingNames);
|
|
447
|
+
if (!existing.has(filename))
|
|
448
|
+
return filename;
|
|
449
|
+
let n = 2;
|
|
450
|
+
while (existing.has(`${filename}.${n}`))
|
|
451
|
+
n++;
|
|
452
|
+
return `${filename}.${n}`;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Classify one `.key` file given whether its seed parsed (`seedValid`) and,
|
|
456
|
+
* if it did, the registration-gate result checkAgentRegistered (src/cli.ts)
|
|
457
|
+
* returned for it — the SAME check doctor's "not registered" finding above
|
|
458
|
+
* is built from. Pure — no fs/crypto/network; the caller (classifyKeysDir,
|
|
459
|
+
* src/cli.ts) does the actual file read, seed parse, and signed registration
|
|
460
|
+
* check, and only calls this to decide what the result means.
|
|
461
|
+
*
|
|
462
|
+
* `registration` is ignored (pass null) when `seedValid` is false — an
|
|
463
|
+
* unparseable seed can't be signed with, so it was never checked against the
|
|
464
|
+
* instance, regardless of what agentId its filename implies.
|
|
465
|
+
*
|
|
466
|
+
* Deliberately has no case for "unreachable": classifyKeysDir aborts the
|
|
467
|
+
* WHOLE run before classifying anything once the instance is confirmed
|
|
468
|
+
* unreachable (never classify offline) — this function is only ever called
|
|
469
|
+
* once that's already been ruled out.
|
|
470
|
+
*/
|
|
471
|
+
export function classifyKeyFile(agentId, seedValid, registration, baseUrl) {
|
|
472
|
+
if (!seedValid) {
|
|
473
|
+
return { class: "invalid", reason: "not a parseable Ed25519 private key seed" };
|
|
474
|
+
}
|
|
475
|
+
if (registration?.state === "registered") {
|
|
476
|
+
return { class: "keep", reason: `agent '${agentId}' is registered on ${baseUrl} — never pruned` };
|
|
477
|
+
}
|
|
478
|
+
// "not-registered", "no-key", or (defensively) no registration result at
|
|
479
|
+
// all — every one of those means we could not confirm this agent is
|
|
480
|
+
// registered, so it's prunable. "no-key" is not expected in practice here
|
|
481
|
+
// (the file we just parsed a valid seed FROM is itself the key
|
|
482
|
+
// checkAgentRegistered would sign with), but is handled the same way
|
|
483
|
+
// rather than left as an unclassified gap.
|
|
484
|
+
return {
|
|
485
|
+
class: "stale",
|
|
486
|
+
reason: `agent '${agentId}' is not registered on ${baseUrl}${registration?.detail ? ` (${registration.detail})` : ""}`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// ─── `flair hook install` — ambient memory via harness SessionStart hooks (flair#745) ──
|
|
2
|
+
//
|
|
3
|
+
// Design record: https://github.com/tpsdev-ai/flair/issues/719 ("Paved-paths
|
|
4
|
+
// design round" — the `flair hook install` section) + Kern's and Sherlock's
|
|
5
|
+
// verdicts on that thread. Issue: https://github.com/tpsdev-ai/flair/issues/745
|
|
6
|
+
//
|
|
7
|
+
// `flair doctor --fix` and `flair init` already wire a SessionStart hook into
|
|
8
|
+
// ~/.claude/settings.json (src/doctor-client.ts's checkSessionStartHook /
|
|
9
|
+
// fixSessionStartHook, driven by `applyOrReportSessionStartHook`) — but that
|
|
10
|
+
// wiring is a side effect of a broader diagnostic/setup flow, not a
|
|
11
|
+
// standalone, symmetric, testable command a user or an automation can run on
|
|
12
|
+
// its own. This module is the pure (no network, no process spawn) decision
|
|
13
|
+
// logic behind the new top-level `flair hook install|uninstall|status`
|
|
14
|
+
// command family (wired into src/cli.ts). It intentionally reuses
|
|
15
|
+
// doctor-client.ts's SESSION_START_HOOK_MARKER as the single source of truth
|
|
16
|
+
// for "is this our hook" — so `flair doctor`'s existing check keeps
|
|
17
|
+
// recognizing anything this module writes with ZERO changes to that check.
|
|
18
|
+
// The one deliberate shape difference: this module's command always sets
|
|
19
|
+
// BOTH FLAIR_AGENT_ID and FLAIR_URL (mirroring src/install/clients.ts's
|
|
20
|
+
// WireEnv/flairMcpEntry, which does the same for the MCP server block),
|
|
21
|
+
// where doctor/init's minimal shape sets only FLAIR_AGENT_ID. That addition
|
|
22
|
+
// never breaks doctor's marker-substring check (the marker is still present
|
|
23
|
+
// verbatim), and is what makes a remote-instance install actually target the
|
|
24
|
+
// remote instance instead of silently falling back to flair-mcp's localhost
|
|
25
|
+
// default.
|
|
26
|
+
//
|
|
27
|
+
// Binding review conditions (Sherlock, #719 thread) this module implements:
|
|
28
|
+
// 1. Malformed settings.json fails CLOSED — a backup is taken BEFORE the
|
|
29
|
+
// parse attempt (whenever the file exists and we're not in --dry-run),
|
|
30
|
+
// and on a parse error we report and refuse to touch the real file:
|
|
31
|
+
// never truncate, never write a partial replacement.
|
|
32
|
+
// 2. Idempotent merge — parse, add/update ONLY our hook entry (found via
|
|
33
|
+
// the SESSION_START_HOOK_MARKER substring, exactly like doctor's own
|
|
34
|
+
// check), never touch unrelated hooks/keys. Re-running with unchanged
|
|
35
|
+
// inputs is a byte-identical no-op; re-running with a changed
|
|
36
|
+
// agent/URL updates just that one hook's `command` field in place.
|
|
37
|
+
// 3. --dry-run computes the exact delta (before/after hook group) without
|
|
38
|
+
// writing anything — no backup either, since a backup is itself a write.
|
|
39
|
+
// 4. Remote-instance transport — this module never touches HTTP/TLS at
|
|
40
|
+
// all (see packages/flair-mcp/src/session-start-hook.ts, which uses
|
|
41
|
+
// FlairClient's plain global `fetch`, no rejectUnauthorized/NODE_TLS_*
|
|
42
|
+
// bypass anywhere — test/unit/hook-install.test.ts asserts that
|
|
43
|
+
// statically).
|
|
44
|
+
// 5. Silent-fast degradation — also owned by session-start-hook.ts (hard
|
|
45
|
+
// timeout, no-op-on-any-failure); this module only writes the pointer
|
|
46
|
+
// to it.
|
|
47
|
+
// 6. Size-budgeted payload — also owned by session-start-hook.ts, which
|
|
48
|
+
// reuses bootstrap's own maxTokens machinery.
|
|
49
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
50
|
+
import { dirname, join } from "node:path";
|
|
51
|
+
import { SESSION_START_HOOK_MARKER } from "./doctor-client.js";
|
|
52
|
+
// ── harness registry ────────────────────────────────────────────────────────
|
|
53
|
+
/** v1 supports exactly one harness. The flag/type exist so a second harness
|
|
54
|
+
* is an additive registry entry, not a rewrite (Kern's #719 verdict: "a
|
|
55
|
+
* switch statement... is fine until we have 3+ harnesses"). */
|
|
56
|
+
export const SUPPORTED_HARNESSES = ["claude-code"];
|
|
57
|
+
export function isSupportedHarness(value) {
|
|
58
|
+
return SUPPORTED_HARNESSES.includes(value);
|
|
59
|
+
}
|
|
60
|
+
/** Where this harness's hook config lives, given a home directory (never
|
|
61
|
+
* reads process.env.HOME itself — callers pass homedir() in production and
|
|
62
|
+
* a temp dir in tests, mirroring doctor-client.ts's withHome technique). */
|
|
63
|
+
export function hookSettingsPath(homeDir, harness) {
|
|
64
|
+
switch (harness) {
|
|
65
|
+
case "claude-code":
|
|
66
|
+
return join(homeDir, ".claude", "settings.json");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Backup path convention: a single sibling `<path>.bak`, overwritten on
|
|
70
|
+
* every mutating run — recovery insurance for the mutation that's about to
|
|
71
|
+
* happen, not a version history. Exported so tests assert against the same
|
|
72
|
+
* constant this module uses internally. */
|
|
73
|
+
export function hookBackupPath(settingsPath) {
|
|
74
|
+
return `${settingsPath}.bak`;
|
|
75
|
+
}
|
|
76
|
+
// ── the hook command itself ─────────────────────────────────────────────────
|
|
77
|
+
/** The exact `command` string written into the SessionStart hook entry.
|
|
78
|
+
* Always carries both FLAIR_AGENT_ID and FLAIR_URL (see module doc above)
|
|
79
|
+
* and always contains SESSION_START_HOOK_MARKER verbatim, so doctor's
|
|
80
|
+
* existing checkSessionStartHook recognizes it unchanged. */
|
|
81
|
+
export function buildHookCommand(agentId, flairUrl) {
|
|
82
|
+
return `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl} npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
|
|
83
|
+
}
|
|
84
|
+
/** Best-effort recovery of the agentId/flairUrl a previously-wired hook
|
|
85
|
+
* command carries — used by `flair hook status`. Pure string scan, never
|
|
86
|
+
* throws on an unexpected shape. */
|
|
87
|
+
export function parseHookCommandEnv(command) {
|
|
88
|
+
const agentMatch = command.match(/FLAIR_AGENT_ID=(\S+)/);
|
|
89
|
+
const urlMatch = command.match(/FLAIR_URL=(\S+)/);
|
|
90
|
+
return { agentId: agentMatch?.[1], flairUrl: urlMatch?.[1] };
|
|
91
|
+
}
|
|
92
|
+
function makeHookGroup(command) {
|
|
93
|
+
return { hooks: [{ type: "command", command }] };
|
|
94
|
+
}
|
|
95
|
+
function deepClone(value) {
|
|
96
|
+
return JSON.parse(JSON.stringify(value));
|
|
97
|
+
}
|
|
98
|
+
/** Locate our hook (by marker substring, exactly like doctor's
|
|
99
|
+
* checkSessionStartHook) inside a parsed settings object, if present.
|
|
100
|
+
* Returns array indices (not the doctor-client boolean) since install/
|
|
101
|
+
* uninstall need to mutate/splice in place without disturbing siblings. */
|
|
102
|
+
function findHookEntry(config) {
|
|
103
|
+
const groups = config?.hooks?.SessionStart;
|
|
104
|
+
if (!Array.isArray(groups))
|
|
105
|
+
return null;
|
|
106
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
107
|
+
const hooks = groups[gi]?.hooks;
|
|
108
|
+
if (!Array.isArray(hooks))
|
|
109
|
+
continue;
|
|
110
|
+
for (let hi = 0; hi < hooks.length; hi++) {
|
|
111
|
+
if (typeof hooks[hi]?.command === "string" && hooks[hi].command.includes(SESSION_START_HOOK_MARKER)) {
|
|
112
|
+
return { groupIndex: gi, hookIndex: hi };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
function readSettingsFile(path) {
|
|
119
|
+
if (!existsSync(path))
|
|
120
|
+
return { exists: false, parsed: {}, parseError: null };
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = readFileSync(path, "utf-8");
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
127
|
+
return { exists: true, parsed: null, parseError: `could not read ${path}: ${reason}` };
|
|
128
|
+
}
|
|
129
|
+
if (!raw.trim())
|
|
130
|
+
return { exists: true, parsed: {}, parseError: null };
|
|
131
|
+
try {
|
|
132
|
+
return { exists: true, parsed: JSON.parse(raw), parseError: null };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
136
|
+
return { exists: true, parsed: null, parseError: `malformed JSON in ${path} (${reason})` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Copy the existing file to its backup path. Caller must only call this
|
|
140
|
+
* when the file exists AND we're about to mutate for real (never during
|
|
141
|
+
* --dry-run — a backup is itself a write). Throws on failure so the caller
|
|
142
|
+
* can fail closed rather than silently proceeding without a safety copy. */
|
|
143
|
+
function takeBackup(path) {
|
|
144
|
+
const dest = hookBackupPath(path);
|
|
145
|
+
copyFileSync(path, dest);
|
|
146
|
+
return dest;
|
|
147
|
+
}
|
|
148
|
+
function computeInstallDelta(config, agentId, flairUrl) {
|
|
149
|
+
const command = buildHookCommand(agentId, flairUrl);
|
|
150
|
+
const after = makeHookGroup(command);
|
|
151
|
+
const existing = findHookEntry(config);
|
|
152
|
+
if (existing) {
|
|
153
|
+
const beforeGroup = config.hooks.SessionStart[existing.groupIndex];
|
|
154
|
+
const beforeSnapshot = deepClone(beforeGroup);
|
|
155
|
+
const beforeCommand = beforeGroup.hooks[existing.hookIndex]?.command;
|
|
156
|
+
if (beforeCommand === command && beforeGroup.hooks.length === 1) {
|
|
157
|
+
return { action: "noop", before: beforeSnapshot, after, newConfig: config };
|
|
158
|
+
}
|
|
159
|
+
const newConfig = deepClone(config);
|
|
160
|
+
// Update ONLY the one matching hook entry — any sibling hooks in the
|
|
161
|
+
// same group (or other groups/keys) are left byte-identical.
|
|
162
|
+
newConfig.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex] = { type: "command", command };
|
|
163
|
+
return { action: "update", before: beforeSnapshot, after, newConfig };
|
|
164
|
+
}
|
|
165
|
+
const newConfig = deepClone(config);
|
|
166
|
+
newConfig.hooks = newConfig.hooks && typeof newConfig.hooks === "object" && !Array.isArray(newConfig.hooks) ? newConfig.hooks : {};
|
|
167
|
+
newConfig.hooks.SessionStart = Array.isArray(newConfig.hooks.SessionStart) ? newConfig.hooks.SessionStart : [];
|
|
168
|
+
newConfig.hooks.SessionStart.push(after);
|
|
169
|
+
return { action: "add", before: null, after, newConfig };
|
|
170
|
+
}
|
|
171
|
+
function computeRemovalDelta(config) {
|
|
172
|
+
const existing = findHookEntry(config);
|
|
173
|
+
if (!existing)
|
|
174
|
+
return { action: "noop", before: null, newConfig: config };
|
|
175
|
+
const beforeSnapshot = deepClone(config.hooks.SessionStart[existing.groupIndex]);
|
|
176
|
+
const newConfig = deepClone(config);
|
|
177
|
+
const group = newConfig.hooks.SessionStart[existing.groupIndex];
|
|
178
|
+
group.hooks.splice(existing.hookIndex, 1);
|
|
179
|
+
if (group.hooks.length === 0) {
|
|
180
|
+
newConfig.hooks.SessionStart.splice(existing.groupIndex, 1);
|
|
181
|
+
}
|
|
182
|
+
if (newConfig.hooks.SessionStart.length === 0) {
|
|
183
|
+
delete newConfig.hooks.SessionStart;
|
|
184
|
+
}
|
|
185
|
+
if (newConfig.hooks && Object.keys(newConfig.hooks).length === 0) {
|
|
186
|
+
delete newConfig.hooks;
|
|
187
|
+
}
|
|
188
|
+
return { action: "remove", before: beforeSnapshot, newConfig };
|
|
189
|
+
}
|
|
190
|
+
/** Idempotent, fail-closed, dry-run-able install of the Flair SessionStart
|
|
191
|
+
* hook into `harness`'s settings file. See module doc for the Sherlock
|
|
192
|
+
* conditions this implements. */
|
|
193
|
+
export function installHook(opts) {
|
|
194
|
+
const { homeDir, harness, agentId, flairUrl } = opts;
|
|
195
|
+
const dryRun = !!opts.dryRun;
|
|
196
|
+
const path = hookSettingsPath(homeDir, harness);
|
|
197
|
+
if (dryRun) {
|
|
198
|
+
const read = readSettingsFile(path);
|
|
199
|
+
if (read.parseError) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false, path, harness, dryRun,
|
|
202
|
+
message: `${read.parseError} — dry run: nothing would be written until this is fixed`,
|
|
203
|
+
backupPath: null, delta: null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const { action, before, after } = computeInstallDelta(read.parsed ?? {}, agentId, flairUrl);
|
|
207
|
+
const delta = { action, path, harness, before, after };
|
|
208
|
+
const message = action === "noop"
|
|
209
|
+
? `already correct in ${path} — no changes`
|
|
210
|
+
: `would ${action} the SessionStart hook in ${path} (dry run — nothing written)`;
|
|
211
|
+
return { ok: true, path, harness, dryRun, message, backupPath: null, delta };
|
|
212
|
+
}
|
|
213
|
+
// Backup BEFORE the parse attempt (Sherlock condition 1) — only meaningful
|
|
214
|
+
// when a file already exists; a fresh install has nothing to protect.
|
|
215
|
+
let backupPath = null;
|
|
216
|
+
if (existsSync(path)) {
|
|
217
|
+
try {
|
|
218
|
+
backupPath = takeBackup(path);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
222
|
+
return {
|
|
223
|
+
ok: false, path, harness, dryRun,
|
|
224
|
+
message: `could not back up ${path} before mutating it: ${reason} — refusing to touch it`,
|
|
225
|
+
backupPath: null, delta: null,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const read = readSettingsFile(path);
|
|
230
|
+
if (read.parseError) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false, path, harness, dryRun,
|
|
233
|
+
message: `${read.parseError} — refusing to modify a file we can't safely parse. Original left untouched at ${path}` +
|
|
234
|
+
(backupPath ? `; backup copy at ${backupPath}.` : "."),
|
|
235
|
+
backupPath, delta: null,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const { action, before, after, newConfig } = computeInstallDelta(read.parsed ?? {}, agentId, flairUrl);
|
|
239
|
+
const delta = { action, path, harness, before, after };
|
|
240
|
+
if (action === "noop") {
|
|
241
|
+
return { ok: true, path, harness, dryRun, message: `SessionStart hook already correct in ${path}`, backupPath, delta };
|
|
242
|
+
}
|
|
243
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
244
|
+
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
|
|
245
|
+
return {
|
|
246
|
+
ok: true, path, harness, dryRun,
|
|
247
|
+
message: `${action === "add" ? "added" : "updated"} the SessionStart hook in ${path}`,
|
|
248
|
+
backupPath, delta,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** Symmetric removal — deletes ONLY our hook entry (found the same way
|
|
252
|
+
* install finds it: SESSION_START_HOOK_MARKER substring match), never
|
|
253
|
+
* touches unrelated hooks/keys. A no-op (ok:true, action "noop") when
|
|
254
|
+
* nothing is installed — never creates a file that didn't already exist. */
|
|
255
|
+
export function uninstallHook(opts) {
|
|
256
|
+
const { homeDir, harness } = opts;
|
|
257
|
+
const dryRun = !!opts.dryRun;
|
|
258
|
+
const path = hookSettingsPath(homeDir, harness);
|
|
259
|
+
if (dryRun) {
|
|
260
|
+
const read = readSettingsFile(path);
|
|
261
|
+
if (read.parseError) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false, path, harness, dryRun,
|
|
264
|
+
message: `${read.parseError} — dry run: nothing would be removed until this is fixed`,
|
|
265
|
+
backupPath: null, delta: null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const { action, before } = computeRemovalDelta(read.parsed ?? {});
|
|
269
|
+
const delta = { action, path, harness, before, after: null };
|
|
270
|
+
const message = action === "noop"
|
|
271
|
+
? `no Flair SessionStart hook found in ${path} — nothing to remove`
|
|
272
|
+
: `would remove the Flair SessionStart hook from ${path} (dry run — nothing written)`;
|
|
273
|
+
return { ok: true, path, harness, dryRun, message, backupPath: null, delta };
|
|
274
|
+
}
|
|
275
|
+
let backupPath = null;
|
|
276
|
+
if (existsSync(path)) {
|
|
277
|
+
try {
|
|
278
|
+
backupPath = takeBackup(path);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
282
|
+
return {
|
|
283
|
+
ok: false, path, harness, dryRun,
|
|
284
|
+
message: `could not back up ${path} before mutating it: ${reason} — refusing to touch it`,
|
|
285
|
+
backupPath: null, delta: null,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const read = readSettingsFile(path);
|
|
290
|
+
if (read.parseError) {
|
|
291
|
+
return {
|
|
292
|
+
ok: false, path, harness, dryRun,
|
|
293
|
+
message: `${read.parseError} — refusing to modify a file we can't safely parse. Original left untouched at ${path}` +
|
|
294
|
+
(backupPath ? `; backup copy at ${backupPath}.` : "."),
|
|
295
|
+
backupPath, delta: null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const { action, before, newConfig } = computeRemovalDelta(read.parsed ?? {});
|
|
299
|
+
const delta = { action, path, harness, before, after: null };
|
|
300
|
+
if (action === "noop") {
|
|
301
|
+
return { ok: true, path, harness, dryRun, message: `no Flair SessionStart hook found in ${path} — nothing to remove`, backupPath, delta };
|
|
302
|
+
}
|
|
303
|
+
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
|
|
304
|
+
return { ok: true, path, harness, dryRun, message: `removed the Flair SessionStart hook from ${path}`, backupPath, delta };
|
|
305
|
+
}
|
|
306
|
+
/** Read-only report: is the hook wired, does it look right, and which agent
|
|
307
|
+
* / Flair instance does it point at (recovered from the wired command). */
|
|
308
|
+
export function hookStatus(homeDir, harness) {
|
|
309
|
+
const path = hookSettingsPath(homeDir, harness);
|
|
310
|
+
const read = readSettingsFile(path);
|
|
311
|
+
if (read.parseError) {
|
|
312
|
+
return { harness, path, wired: false, correctShape: false, parseError: read.parseError };
|
|
313
|
+
}
|
|
314
|
+
const config = read.parsed ?? {};
|
|
315
|
+
const existing = findHookEntry(config);
|
|
316
|
+
if (!existing) {
|
|
317
|
+
return { harness, path, wired: false, correctShape: false, parseError: null };
|
|
318
|
+
}
|
|
319
|
+
const hookEntry = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex];
|
|
320
|
+
const command = typeof hookEntry?.command === "string" ? hookEntry.command : "";
|
|
321
|
+
const correctShape = hookEntry?.type === "command" && command.includes(`npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`);
|
|
322
|
+
const env = parseHookCommandEnv(command);
|
|
323
|
+
return { harness, path, wired: true, correctShape, agentId: env.agentId, flairUrl: env.flairUrl, command, parseError: null };
|
|
324
|
+
}
|
package/dist/install/clients.js
CHANGED
|
@@ -110,15 +110,22 @@ function flairMcpEntry(env) {
|
|
|
110
110
|
return {
|
|
111
111
|
command: "npx",
|
|
112
112
|
args: ["-y", "@tpsdev-ai/flair-mcp"],
|
|
113
|
-
env: {
|
|
113
|
+
env: {
|
|
114
|
+
FLAIR_AGENT_ID: env.FLAIR_AGENT_ID,
|
|
115
|
+
FLAIR_URL: env.FLAIR_URL,
|
|
116
|
+
// flair#718 — only present when the caller set it; absent = omitted,
|
|
117
|
+
// not written as FLAIR_CLIENT: undefined.
|
|
118
|
+
...(env.FLAIR_CLIENT ? { FLAIR_CLIENT: env.FLAIR_CLIENT } : {}),
|
|
119
|
+
},
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
122
|
/** Pretty-printed JSON `mcpServers.flair` snippet for copy-paste fallbacks. */
|
|
117
123
|
function jsonSnippet(env) {
|
|
118
124
|
return JSON.stringify({ mcpServers: { flair: flairMcpEntry(env) } }, null, 2);
|
|
119
125
|
}
|
|
120
|
-
/** TOML `[mcp_servers.flair]` snippet (Codex format).
|
|
121
|
-
|
|
126
|
+
/** TOML `[mcp_servers.flair]` snippet (Codex format). Exported for tests
|
|
127
|
+
* (flair#727 — asserts the rendered template carries a full scheme+port URL). */
|
|
128
|
+
export function tomlSnippet(env) {
|
|
122
129
|
return [
|
|
123
130
|
`[mcp_servers.flair]`,
|
|
124
131
|
`command = "npx"`,
|
|
@@ -127,8 +134,32 @@ function tomlSnippet(env) {
|
|
|
127
134
|
`[mcp_servers.flair.env]`,
|
|
128
135
|
`FLAIR_AGENT_ID = "${env.FLAIR_AGENT_ID}"`,
|
|
129
136
|
`FLAIR_URL = "${env.FLAIR_URL}"`,
|
|
137
|
+
// flair#718 — only present when the caller set it (same rule as flairMcpEntry above).
|
|
138
|
+
...(env.FLAIR_CLIENT ? [`FLAIR_CLIENT = "${env.FLAIR_CLIENT}"`] : []),
|
|
130
139
|
].join("\n");
|
|
131
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* True when `raw` TOML content already has a `[mcp_servers.flair]` header —
|
|
143
|
+
* the same detection scanCodexFlairBlock (src/doctor-client.ts) uses to
|
|
144
|
+
* decide whether the block is present. Pure string scan; no TOML parser
|
|
145
|
+
* (see the comment on _wireCodex below for why).
|
|
146
|
+
*/
|
|
147
|
+
export function codexConfigHasFlairSection(raw) {
|
|
148
|
+
return /^\[mcp_servers\.flair\]\s*$/m.test(raw);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Pure merge: append the Flair TOML snippet to existing raw config.toml
|
|
152
|
+
* content. Callers MUST first confirm codexConfigHasFlairSection(raw) is
|
|
153
|
+
* false — appending a second `[mcp_servers.flair]` table would shadow/
|
|
154
|
+
* duplicate the first (TOML doesn't merge repeated table headers), so this
|
|
155
|
+
* function does not re-check; it just appends safely with a newline
|
|
156
|
+
* separator (mirrors fixClaudeMdBootstrap's separator logic in
|
|
157
|
+
* src/doctor-client.ts — never runs the new block into the prior line).
|
|
158
|
+
*/
|
|
159
|
+
export function appendCodexFlairBlock(raw, env) {
|
|
160
|
+
const separator = raw.length === 0 ? "" : raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
|
|
161
|
+
return raw + separator + tomlSnippet(env) + "\n";
|
|
162
|
+
}
|
|
132
163
|
/**
|
|
133
164
|
* Merge the Flair MCP server into a JSON config file with an `mcpServers` map.
|
|
134
165
|
* Creates the file (and parent dir) if absent; preserves existing servers and
|
|
@@ -211,18 +242,22 @@ function _wireClaudeCode(env) {
|
|
|
211
242
|
}
|
|
212
243
|
function _wireCodex(env) {
|
|
213
244
|
// Codex uses TOML with a [mcp_servers.flair] table. We don't carry a TOML
|
|
214
|
-
// parser,
|
|
215
|
-
//
|
|
216
|
-
//
|
|
245
|
+
// parser, but appending a new top-level table at EOF is safe TOML when the
|
|
246
|
+
// exact header isn't already present (flair#727) — so an existing file only
|
|
247
|
+
// forces the manual-print fallback when it's genuinely unreadable/
|
|
248
|
+
// unwritable (permissions, I/O error), never merely "exists". A file that
|
|
249
|
+
// already has the section is reported already-wired, matching the JSON
|
|
250
|
+
// clients' idempotency (wireJsonMcp above).
|
|
217
251
|
const path = codexConfigPath();
|
|
218
252
|
const display = "~/.codex/config.toml";
|
|
219
253
|
try {
|
|
220
254
|
if (existsSync(path)) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
message: `Codex:
|
|
224
|
-
|
|
225
|
-
|
|
255
|
+
const raw = readFileSync(path, "utf-8");
|
|
256
|
+
if (codexConfigHasFlairSection(raw)) {
|
|
257
|
+
return { ok: true, message: `Codex: already wired in ${display}` };
|
|
258
|
+
}
|
|
259
|
+
writeFileSync(path, appendCodexFlairBlock(raw, env));
|
|
260
|
+
return { ok: true, message: `Codex: wired ${display} (restart Codex to pick it up)` };
|
|
226
261
|
}
|
|
227
262
|
mkdirSync(dirname(path), { recursive: true });
|
|
228
263
|
writeFileSync(path, tomlSnippet(env) + "\n");
|