@tpsdev-ai/flair 0.23.0 → 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/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("exit", (code) => resolveP({ code, output: chunks.join("") }));
260
+ p.on("close", (code) => resolveP({ code, output: chunks.join("") }));
246
261
  });
247
262
  }
248
263
  function sleep(ms) {
@@ -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
+ }