@tpsdev-ai/flair 0.48.0 → 0.49.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.
@@ -214,16 +214,51 @@ export function planComponentEnv(existing, publicUrl) {
214
214
  assertNoSecretKeysAdded(existing, text);
215
215
  return { action: "added", text, effectiveValue: publicUrl, notices };
216
216
  }
217
+ /**
218
+ * True when `envPath` is inside a `node_modules` tree (any platform separator).
219
+ *
220
+ * A `.env` there is not a durable location: it does not exist on a stock npm
221
+ * install and `npm upgrade` / `flair upgrade` wipes the package directory
222
+ * (flair#1313). Doctor and deploy must never name that path as the fix.
223
+ *
224
+ * Heuristic, not a guarantee: a path segment equal to `node_modules` is treated
225
+ * as the npm tree. A durable deploy root that happened to use that as a
226
+ * directory name (e.g. `/opt/my-node_modules-app/flair/.env`) would be
227
+ * misclassified. That is not a real deployment shape.
228
+ */
229
+ export function isNodeModulesEnvPath(envPath) {
230
+ // Path-segment match, not a substring of a filename — still a heuristic.
231
+ return envPath.split(/[\\/]/).includes("node_modules");
232
+ }
233
+ /**
234
+ * The location `publicUrlRemedy` names when the component path is inside
235
+ * `node_modules`. Three durable channels, matching what the deploy actually
236
+ * reads: the process environment that starts Harper (CLI / launchd / systemd),
237
+ * or the component `.env` on a Fabric/server deploy (`loadEnv` in config.yaml).
238
+ */
239
+ export const DURABLE_PUBLIC_URL_LOCATION = "the Flair process environment (launchd EnvironmentVariables, systemd Environment=, " +
240
+ "or export before flair start/restart) or, on a Fabric/server deploy, the component " +
241
+ `${COMPONENT_ENV_FILENAME} that Harper's loadEnv plugin reads — never a ${COMPONENT_ENV_FILENAME} ` +
242
+ "inside node_modules (that path does not exist by default and is wiped on every upgrade)";
217
243
  /**
218
244
  * The remedy string for a missing/loopback `FLAIR_PUBLIC_URL`. One definition so
219
245
  * `flair deploy` and `flair doctor` cannot drift into naming different files.
220
246
  *
221
- * It names all three things an operator needs: the FILE, the KEY, and the fact that
222
- * the file is only read because config.yaml declares Harper's `loadEnv` plugin —
223
- * without which the file is present and inert, which is what made flair#1000 hard
224
- * to see.
247
+ * When `envPath` is a durable component location (the deploy root, a server
248
+ * component dir), it names the FILE, the KEY, and the fact that the file is
249
+ * only read because config.yaml declares Harper's `loadEnv` plugin without
250
+ * which the file is present and inert, which is what made flair#1000 hard to
251
+ * see.
252
+ *
253
+ * When `envPath` is inside `node_modules` (a global `npm install -g` package
254
+ * dir), naming that file would send the operator to a path that does not exist
255
+ * by default and is destroyed on every upgrade (flair#1313). The remedy then
256
+ * names the durable process-environment / server-component channels instead.
225
257
  */
226
258
  export function publicUrlRemedy(envPath, exampleUrl = "https://flair.example.com") {
259
+ if (isNodeModulesEnvPath(envPath)) {
260
+ return `set ${PUBLIC_URL_KEY}=${exampleUrl} in ${DURABLE_PUBLIC_URL_LOCATION}, then restart the instance`;
261
+ }
227
262
  return (`set ${PUBLIC_URL_KEY}=${exampleUrl} in ${envPath} (Harper reads a component's ` +
228
263
  `${COMPONENT_ENV_FILENAME} only because flair's config.yaml declares the loadEnv plugin, ` +
229
264
  `above jsResource), then restart the instance`);
@@ -257,6 +292,19 @@ export function describePublicUrlFinding(input) {
257
292
  };
258
293
  }
259
294
  if (componentEnvValue !== null && !isLoopbackUrl(componentEnvValue)) {
295
+ if (isNodeModulesEnvPath(componentEnvPath)) {
296
+ // The value is in an upgrade-wiped location. Naming that path — even to
297
+ // say "confirm loadEnv" — would send the operator back into node_modules
298
+ // (flair#1313). Move the value to a durable channel.
299
+ return {
300
+ isIssue: true,
301
+ icon: "error",
302
+ message: `${PUBLIC_URL_KEY} is set in a ${COMPONENT_ENV_FILENAME} inside the npm package ` +
303
+ `directory but discovery still advertises ${advertisedIssuer} — that file is ` +
304
+ `wiped on every upgrade and is not a durable location`,
305
+ fixHint: publicUrlRemedy(componentEnvPath, componentEnvValue),
306
+ };
307
+ }
260
308
  return {
261
309
  isIssue: true,
262
310
  icon: "error",
@@ -22,7 +22,7 @@
22
22
  import { spawnSync } from "node:child_process";
23
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
- import { ALL_CLIENTS, clientConfigPath } from "./install/clients.js";
25
+ import { ALL_CLIENTS, clientConfigPath, piSettingsPath, resolvePiExtensionPath, scanPiSettings, } from "./install/clients.js";
26
26
  import { FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
27
27
  // The exact substring `flair init` writes into CLAUDE.md (src/cli.ts, the
28
28
  // `init` action) and that the doctor check + fix both key off of.
@@ -610,6 +610,51 @@ function scanCodexFlairBlock(raw) {
610
610
  const present = !!agentId;
611
611
  return { present, agentId, flairUrl, urlDefaulted: present && !flairUrl };
612
612
  }
613
+ export function checkPiFlairWiring(homeDir, cwd) {
614
+ const userPath = withHome(homeDir, () => piSettingsPath());
615
+ const files = [
616
+ // pi resolves user-scope relative paths against the agent dir (the
617
+ // settings file's own directory), project-scope against the project dir.
618
+ { path: userPath, baseDir: dirname(userPath) },
619
+ ];
620
+ if (cwd)
621
+ files.push({ path: join(cwd, ".pi", "settings.json"), baseDir: cwd });
622
+ const report = {
623
+ settingsPath: userPath,
624
+ checked: [],
625
+ wired: false,
626
+ wiredVia: null,
627
+ pinnedVersion: null,
628
+ misconfigured: [],
629
+ };
630
+ for (const file of files) {
631
+ const raw = readTextFile(file.path);
632
+ report.checked.push({ path: file.path, exists: raw !== null });
633
+ const scan = scanPiSettings(raw);
634
+ for (const entry of scan.misconfiguredNpmUnderExtensions) {
635
+ report.misconfigured.push({ path: file.path, entry });
636
+ }
637
+ if (report.wired)
638
+ continue; // first wiring found wins; keep collecting traps
639
+ if (scan.packagesSpec) {
640
+ report.wired = true;
641
+ report.wiredVia = "packages";
642
+ report.wiredIn = file.path;
643
+ report.spec = scan.packagesSpec;
644
+ report.pinnedVersion = scan.pinnedVersion;
645
+ continue;
646
+ }
647
+ if (scan.extensionFilePaths.length > 0) {
648
+ const entry = scan.extensionFilePaths[0];
649
+ report.wired = true;
650
+ report.wiredVia = "extension-path";
651
+ report.wiredIn = file.path;
652
+ report.spec = entry;
653
+ report.extensionPathExists = existsSync(resolvePiExtensionPath(entry, homeDir, file.baseDir));
654
+ }
655
+ }
656
+ return report;
657
+ }
613
658
  // ── check 2: FLAIR_URL to use when (re-)wiring a client (flair#727) ────────
614
659
  /**
615
660
  * Pick the FLAIR_URL to feed a wire() call when `doctor --fix` re-wires a
@@ -94,14 +94,62 @@ export function hookBackupPath(settingsPath) {
94
94
  export function buildHookCommand(agentId, flairUrl) {
95
95
  return buildSessionStartHookCommand(agentId, flairUrl);
96
96
  }
97
+ /**
98
+ * Peel the installer `sh -c '...'` wrapper (and its `out=$(...)` capture)
99
+ * so env assignments can be read from the inner invocation. Leaves a bare
100
+ * command (legacy pre-#1007, hand-rolled) unchanged. Never throws.
101
+ */
102
+ function unwrapInstallerHookCommand(command) {
103
+ const shc = command.match(/^sh\s+-c\s+(['"])([\s\S]*)\1\s*$/);
104
+ const body = shc ? shc[2] : command;
105
+ // SessionStart installer: `out=$(<invocation> 2>/dev/null) && printf ...`
106
+ const captured = body.match(/^out=\$\((.*)\)\s*&&/);
107
+ return captured ? captured[1] : body;
108
+ }
109
+ /** Env values the installer interpolates are allow-listed (see
110
+ * isHookCommandValueSafe). Stop before whitespace or the shell
111
+ * metacharacters the `$(...)` wrapper can leave adjacent to a value. */
112
+ const HOOK_ENV_VALUE_RE = /[^\s'"$();|&<>]+/;
97
113
  /** Best-effort recovery of the agentId/flairUrl a previously-wired hook
98
- * command carries — used by `flair hook status`. Pure string scan, never
99
- * throws on an unexpected shape. */
114
+ * command carries — used by `flair hook status`. Understands the
115
+ * installer-written `sh -c` wrapper (`flair init`, `flair hook install`,
116
+ * docs/mcp-clients.md) as well as a bare invocation. Pure string scan,
117
+ * never throws on an unexpected shape. A missing FLAIR_URL is not a
118
+ * parse failure: `flair init` / doctor's minimal shape omit it on
119
+ * purpose (the hook then uses flair-client's localhost default). */
100
120
  export function parseHookCommandEnv(command) {
101
- const agentMatch = command.match(/FLAIR_AGENT_ID=(\S+)/);
102
- const urlMatch = command.match(/FLAIR_URL=(\S+)/);
121
+ const source = unwrapInstallerHookCommand(command);
122
+ const agentMatch = source.match(new RegExp(`FLAIR_AGENT_ID=(${HOOK_ENV_VALUE_RE.source})`));
123
+ const urlMatch = source.match(new RegExp(`FLAIR_URL=(${HOOK_ENV_VALUE_RE.source})`));
103
124
  return { agentId: agentMatch?.[1], flairUrl: urlMatch?.[1] };
104
125
  }
126
+ /** Printed by `flair hook status` only when the command is wired but its
127
+ * agent/URL really could not be recovered — never for the installer
128
+ * `sh -c` form that simply omits FLAIR_URL (flair#1325). */
129
+ export const HOOK_STATUS_UNPARSED = "(unknown — could not parse command)";
130
+ /** Agent / Flair URL lines `flair hook status` prints under a wired hook.
131
+ * Recovered values are shown. The installer-no-URL omit (flair#1325) is
132
+ * allowed ONLY when agentId was parsed — that is the real `flair init`
133
+ * shape (`FLAIR_AGENT_ID` set, `FLAIR_URL` omitted). correctShape alone
134
+ * is not enough: it is an npx-substring check and a wired correct-shape
135
+ * command with no env assignments must still show the unknown lines,
136
+ * not a silent all-clear. */
137
+ export function hookStatusIdentityLines(status) {
138
+ const lines = [];
139
+ if (status.agentId) {
140
+ lines.push({ label: "Agent", value: status.agentId });
141
+ }
142
+ else {
143
+ lines.push({ label: "Agent", value: HOOK_STATUS_UNPARSED });
144
+ }
145
+ if (status.flairUrl) {
146
+ lines.push({ label: "Flair URL", value: status.flairUrl });
147
+ }
148
+ else if (!status.agentId) {
149
+ lines.push({ label: "Flair URL", value: HOOK_STATUS_UNPARSED });
150
+ }
151
+ return lines;
152
+ }
105
153
  function makeHookGroup(command) {
106
154
  return { hooks: [{ type: "command", command }] };
107
155
  }
@@ -1,9 +1,12 @@
1
1
  // ─── Client Detection & Wiring ──────────────────────────────────────────────────────
2
2
  //
3
- // Detects locally installed MCP clients and wires them to Flair.
4
- // Each client has:
5
- // - detect(): boolean - returns true if client is installed
6
- // - wire(options: { agentId: string; flairUrl: string }): { ok: boolean; message: string }
3
+ // Detects locally installed clients and wires them to Flair. Most are MCP
4
+ // clients (kind: "mcp" — wired via an mcpServers block/TOML table running
5
+ // @tpsdev-ai/flair-mcp); pi is a native-extension host (kind:
6
+ // "native-extension" wired via pi's own settings.json `packages` key,
7
+ // flair#1342). Each client has:
8
+ // - detection: `bin` on PATH, optionally widened by a declared detect() override
9
+ // - wire(env): { ok: boolean; message: string }
7
10
  //
8
11
  // Wiring contract (FIX 4 — onboarding dogfood round 1):
9
12
  // "wired" MUST mean a config file was actually written. A wire function returns
@@ -17,7 +20,7 @@
17
20
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
21
  import { homedir } from "node:os";
19
22
  import { dirname, join } from "node:path";
20
- import { mcpServerSpec } from "../lib/mcp-spec.js";
23
+ import { flairCliVersion, isResolvedVersion, mcpServerSpec } from "../lib/mcp-spec.js";
21
24
  /**
22
25
  * Resolve the user's home dir. Prefer the live HOME/USERPROFILE env over
23
26
  * os.homedir(), which caches the value at process start and so ignores a
@@ -263,6 +266,282 @@ function geminiConfigPath() {
263
266
  function codexConfigPath() {
264
267
  return join(resolveHome(), ".codex", "config.toml");
265
268
  }
269
+ // ---- pi (native extension — NOT an MCP client) ------------------------------------
270
+ //
271
+ // pi has no MCP client support (packages/pi-flair/README "Design Decision"), so
272
+ // Flair ships @tpsdev-ai/pi-flair as a NATIVE pi extension. Wiring pi therefore
273
+ // means editing pi's OWN settings, not writing an mcpServers block (flair#1342):
274
+ //
275
+ // ~/.pi/agent/settings.json (user scope; pi's getSettingsPath() —
276
+ // agent dir overridable via the
277
+ // PI_CODING_AGENT_DIR env var, honored here)
278
+ // <project>/.pi/settings.json (project scope)
279
+ //
280
+ // Two settings keys matter, and confusing them is the flair#1346 field failure:
281
+ //
282
+ // "packages" — package SOURCES (`npm:`, `git:`, local paths, or
283
+ // { source, ...filters } objects). pi parses these through
284
+ // parseSource() and AUTO-INSTALLS a missing/mismatched npm
285
+ // package at resource collection (package-manager.js
286
+ // resolvePackageSources), honoring an exact `@<version>` pin.
287
+ // This is where npm:@tpsdev-ai/pi-flair belongs.
288
+ // "extensions" — local FILE PATHS only. An `npm:` spec here is treated as a
289
+ // path, fails existsSync, and is dropped WITHOUT ERROR — the
290
+ // user believes they are wired and pi registers zero tools.
291
+ // (Verified against pi 0.84.2's package-manager.js: the
292
+ // extensions override list feeds resolvePathFromBase/
293
+ // collectFilesFromPaths, never parseSource.)
294
+ //
295
+ // pi-flair reads FLAIR_AGENT_ID / FLAIR_URL / FLAIR_KEY_PATH from the process
296
+ // environment of the pi that loads it — pi settings carry NO per-package env
297
+ // block, so wiring here cannot pin an agent identity the way the MCP clients'
298
+ // env blocks do. Wire messages say so instead of pretending.
299
+ /** The npm package pi loads as its Flair extension. */
300
+ export const PI_FLAIR_PACKAGE = "@tpsdev-ai/pi-flair";
301
+ /**
302
+ * pi-flair's own DEFAULT_FLAIR_URL (packages/pi-flair/src/index.ts). Duplicated
303
+ * as a value rather than imported — the CLI does not depend on the pi-flair
304
+ * workspace package — by the same convention as doctor-client's
305
+ * FLAIR_CLIENT_DEFAULT_URL; a unit test (pi-client.test.ts) asserts this
306
+ * literal matches pi-flair's source so the two cannot drift silently.
307
+ */
308
+ export const PI_FLAIR_DEFAULT_URL = "http://127.0.0.1:19926";
309
+ /**
310
+ * The `packages` entry a wired pi gets. PINNED for the same reason as
311
+ * mcpServerSpec (flair#907): pi re-resolves an unpinned npm source to latest
312
+ * when (re)installing, and pi-flair ships in version lockstep with the CLI.
313
+ * Falls back to the bare spec when the CLI cannot read its own version — the
314
+ * same condition and caller-owed warning as mcpServerSpec.
315
+ */
316
+ export function piFlairSpec(version = flairCliVersion()) {
317
+ return isResolvedVersion(version)
318
+ ? `npm:${PI_FLAIR_PACKAGE}@${version}`
319
+ : `npm:${PI_FLAIR_PACKAGE}`;
320
+ }
321
+ /** pi's agent config dir: $PI_CODING_AGENT_DIR, else ~/.pi/agent (pi config.js). */
322
+ function piAgentDir() {
323
+ const envDir = process.env.PI_CODING_AGENT_DIR;
324
+ if (envDir)
325
+ return envDir;
326
+ return join(resolveHome(), ".pi", "agent");
327
+ }
328
+ /** pi user-scope settings: <agent dir>/settings.json. */
329
+ export function piSettingsPath() {
330
+ return join(piAgentDir(), "settings.json");
331
+ }
332
+ /** A pi `packages` array entry is a source string or `{ source, ...filters }`. */
333
+ export function piPackageEntrySource(entry) {
334
+ if (typeof entry === "string")
335
+ return entry;
336
+ if (entry && typeof entry === "object" && typeof entry.source === "string") {
337
+ return entry.source;
338
+ }
339
+ return null;
340
+ }
341
+ /** Does this source string name the pi-flair package as an npm source
342
+ * (bare `npm:@tpsdev-ai/pi-flair` or any `npm:@tpsdev-ai/pi-flair@<spec>`)? */
343
+ export function isPiFlairNpmSource(source) {
344
+ if (typeof source !== "string" || !source.startsWith("npm:"))
345
+ return false;
346
+ const spec = source.slice("npm:".length).trim();
347
+ return spec === PI_FLAIR_PACKAGE || spec.startsWith(`${PI_FLAIR_PACKAGE}@`);
348
+ }
349
+ /** The version text of a pinned pi-flair npm source, or null when bare. */
350
+ export function extractPiFlairPin(source) {
351
+ if (!isPiFlairNpmSource(source))
352
+ return null;
353
+ const spec = source.slice("npm:".length).trim();
354
+ const version = spec.slice(`${PI_FLAIR_PACKAGE}@`.length);
355
+ return spec.startsWith(`${PI_FLAIR_PACKAGE}@`) && version ? version : null;
356
+ }
357
+ /**
358
+ * Does this `extensions` entry point at pi-flair BY PATH (the documented
359
+ * pre-0.49 workaround: a local path to the installed dist/index.js)? A
360
+ * substring heuristic on the package/directory name — the entry is user-
361
+ * written free text, so this is deliberately loose in the direction of
362
+ * REPORTING (doctor names the entry it matched); it never gates anything
363
+ * destructive.
364
+ */
365
+ export function isPiFlairExtensionPath(entry) {
366
+ if (typeof entry !== "string" || entry.startsWith("npm:") || entry.startsWith("git:"))
367
+ return false;
368
+ return entry.includes("pi-flair");
369
+ }
370
+ export function scanPiSettings(raw) {
371
+ const empty = { parsed: false, pinnedVersion: null, extensionFilePaths: [], misconfiguredNpmUnderExtensions: [] };
372
+ if (!raw || !raw.trim())
373
+ return empty;
374
+ let config;
375
+ try {
376
+ config = JSON.parse(raw);
377
+ }
378
+ catch {
379
+ return empty;
380
+ }
381
+ if (!config || typeof config !== "object" || Array.isArray(config))
382
+ return empty;
383
+ const cfg = config;
384
+ let packagesSpec;
385
+ let pinnedVersion = null;
386
+ if (Array.isArray(cfg.packages)) {
387
+ for (const entry of cfg.packages) {
388
+ const source = piPackageEntrySource(entry);
389
+ if (source && isPiFlairNpmSource(source)) {
390
+ packagesSpec = source;
391
+ pinnedVersion = extractPiFlairPin(source);
392
+ break;
393
+ }
394
+ }
395
+ }
396
+ const extensionFilePaths = [];
397
+ const misconfiguredNpmUnderExtensions = [];
398
+ if (Array.isArray(cfg.extensions)) {
399
+ for (const entry of cfg.extensions) {
400
+ if (typeof entry !== "string")
401
+ continue;
402
+ if (isPiFlairNpmSource(entry))
403
+ misconfiguredNpmUnderExtensions.push(entry);
404
+ else if (isPiFlairExtensionPath(entry))
405
+ extensionFilePaths.push(entry);
406
+ }
407
+ }
408
+ return { parsed: true, packagesSpec, pinnedVersion, extensionFilePaths, misconfiguredNpmUnderExtensions };
409
+ }
410
+ /**
411
+ * Resolve a pi `extensions` path entry the way pi's loader will: `~/` against
412
+ * the home dir, a relative path against the settings file's own base dir, an
413
+ * absolute path as-is. `baseDir` is the directory pi treats as the scope's
414
+ * base (user scope: the agent dir).
415
+ */
416
+ export function resolvePiExtensionPath(entry, homeDir, baseDir) {
417
+ if (entry.startsWith("~/") || entry === "~")
418
+ return join(homeDir, entry.slice(1));
419
+ if (entry.startsWith("/"))
420
+ return entry;
421
+ return join(baseDir, entry);
422
+ }
423
+ /** Pretty-printed minimal settings snippet for copy-paste fallbacks. */
424
+ function piJsonSnippet() {
425
+ return JSON.stringify({ packages: [piFlairSpec()] }, null, 2);
426
+ }
427
+ /**
428
+ * Wire pi by editing ~/.pi/agent/settings.json `packages` (flair#1342) — the
429
+ * same merge/idempotence/preservation contract as wireJsonMcp: sibling keys
430
+ * and entries survive byte-identical, a current entry is a no-op, a stale pin
431
+ * is refreshed, and ok:true means the file was actually written (or already
432
+ * correct). Two pi-specific rules on top:
433
+ *
434
+ * • an `npm:` pi-flair spec under `extensions` is MOVED to `packages` — that
435
+ * misplacement is silently ignored by pi (flair#1346), so leaving it while
436
+ * adding a packages entry would preserve a decoy;
437
+ * • an existing FILE-PATH `extensions` entry that resolves to a real file is
438
+ * honored as already-wired (the documented pre-0.49 workaround) — the user
439
+ * may deliberately be running a local build, so it is reported, not
440
+ * rewritten.
441
+ *
442
+ * `env` is used for the launch-environment hint only: pi settings have no
443
+ * per-package env block, so FLAIR_AGENT_ID/FLAIR_URL must be exported by
444
+ * whatever shell launches pi — the message says so rather than implying the
445
+ * wiring carried them.
446
+ */
447
+ function _wirePi(env) {
448
+ const path = piSettingsPath();
449
+ const home = resolveHome();
450
+ const display = path.startsWith(home) ? "~" + path.slice(home.length) : path;
451
+ const spec = piFlairSpec();
452
+ const envHint = `pi settings carry no env — export FLAIR_AGENT_ID=${env.FLAIR_AGENT_ID} in the shell that launches pi`;
453
+ try {
454
+ let config = {};
455
+ if (existsSync(path)) {
456
+ const raw = readFileSync(path, "utf-8").trim();
457
+ if (raw)
458
+ config = JSON.parse(raw);
459
+ }
460
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
461
+ throw new Error("settings.json is not a JSON object");
462
+ }
463
+ if (config.packages !== undefined && !Array.isArray(config.packages)) {
464
+ throw new Error(`"packages" exists but is not an array — not rewriting it`);
465
+ }
466
+ if (config.extensions !== undefined && !Array.isArray(config.extensions)) {
467
+ throw new Error(`"extensions" exists but is not an array — not rewriting it`);
468
+ }
469
+ // The #1346 trap: npm: pi-flair specs under `extensions`. Collect + drop.
470
+ let movedFromExtensions = false;
471
+ if (Array.isArray(config.extensions)) {
472
+ const kept = config.extensions.filter((e) => !(typeof e === "string" && isPiFlairNpmSource(e)));
473
+ movedFromExtensions = kept.length !== config.extensions.length;
474
+ if (movedFromExtensions)
475
+ config.extensions = kept;
476
+ }
477
+ // Existing packages entry?
478
+ let entryIndex = -1;
479
+ let entrySource = null;
480
+ if (Array.isArray(config.packages)) {
481
+ for (let i = 0; i < config.packages.length; i++) {
482
+ const source = piPackageEntrySource(config.packages[i]);
483
+ if (source && isPiFlairNpmSource(source)) {
484
+ entryIndex = i;
485
+ entrySource = source;
486
+ break;
487
+ }
488
+ }
489
+ }
490
+ if (!movedFromExtensions && entrySource === spec) {
491
+ return { ok: true, message: `pi: already wired in ${display} (${spec})` };
492
+ }
493
+ if (!movedFromExtensions && entryIndex === -1) {
494
+ // No packages entry and nothing misplaced — honor a working file-path
495
+ // extensions entry (pre-0.49 workaround) instead of double-wiring.
496
+ const scan = scanPiSettings(JSON.stringify(config));
497
+ const workingPath = scan.extensionFilePaths.find((p) => existsSync(resolvePiExtensionPath(p, home, piAgentDir())));
498
+ if (workingPath) {
499
+ return {
500
+ ok: true,
501
+ message: `pi: already wired via a file-path extension in ${display} (${workingPath}) — ` +
502
+ `the pre-0.49 workaround; the canonical form is a "packages" entry: ${spec}`,
503
+ };
504
+ }
505
+ }
506
+ config.packages = Array.isArray(config.packages) ? config.packages : [];
507
+ let action;
508
+ if (entryIndex >= 0) {
509
+ const entry = config.packages[entryIndex];
510
+ if (typeof entry === "string")
511
+ config.packages[entryIndex] = spec;
512
+ else
513
+ entry.source = spec; // object entry: refresh source, keep filters
514
+ action = movedFromExtensions
515
+ ? `moved ${PI_FLAIR_PACKAGE} out of "extensions" and refreshed the "packages" pin in`
516
+ : "refreshed pin in";
517
+ }
518
+ else if (movedFromExtensions) {
519
+ config.packages.push(spec);
520
+ action = `moved ${PI_FLAIR_PACKAGE} from "extensions" to "packages" in`;
521
+ }
522
+ else {
523
+ config.packages.push(spec);
524
+ action = "wired";
525
+ }
526
+ mkdirSync(dirname(path), { recursive: true });
527
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
528
+ const trapNote = movedFromExtensions
529
+ ? ` — pi silently ignores npm: specs under "extensions" (flair#1346)`
530
+ : "";
531
+ return {
532
+ ok: true,
533
+ message: `pi: ${action} ${display} (${spec} — pi installs the package on next launch; ${envHint})${trapNote}`,
534
+ };
535
+ }
536
+ catch (err) {
537
+ const reason = err instanceof Error ? err.message : String(err);
538
+ return {
539
+ ok: false,
540
+ message: `pi: manual wiring needed (could not update ${display}: ${reason}).\n` +
541
+ ` Add this to ${display} (${envHint}):\n${indent(piJsonSnippet())}`,
542
+ };
543
+ }
544
+ }
266
545
  /**
267
546
  * Antigravity CLI (`agy`) + Antigravity 2.0 IDE + SDK: they share ONE central
268
547
  * MCP config at ~/.gemini/config/mcp_config.json on every OS (flair#1209).
@@ -298,6 +577,11 @@ export function clientConfigPath(id) {
298
577
  return cursorConfigPath();
299
578
  case "antigravity":
300
579
  return antigravityConfigPath();
580
+ case "pi":
581
+ // NOT an MCP config: pi's own settings.json, where the pi-flair
582
+ // native-extension wiring lives (flair#1342). readClientMcpBlock over
583
+ // this file correctly reports "no MCP block" — pi never has one.
584
+ return piSettingsPath();
301
585
  }
302
586
  }
303
587
  // ---- Internal wiring functions --------------------------------------------------
@@ -374,24 +658,28 @@ export const ALL_CLIENTS = [
374
658
  id: "claude-code",
375
659
  label: "Claude Code",
376
660
  bin: "claude",
661
+ kind: "mcp",
377
662
  wire: _wireClaudeCode,
378
663
  },
379
664
  {
380
665
  id: "codex",
381
666
  label: "Codex",
382
667
  bin: "codex",
668
+ kind: "mcp",
383
669
  wire: _wireCodex,
384
670
  },
385
671
  {
386
672
  id: "gemini",
387
673
  label: "Gemini",
388
674
  bin: "gemini",
675
+ kind: "mcp",
389
676
  wire: _wireGemini,
390
677
  },
391
678
  {
392
679
  id: "cursor",
393
680
  label: "Cursor",
394
681
  bin: "cursor",
682
+ kind: "mcp",
395
683
  wire: _wireCursor,
396
684
  },
397
685
  {
@@ -399,8 +687,23 @@ export const ALL_CLIENTS = [
399
687
  label: "Antigravity",
400
688
  // Google's Antigravity CLI — the executable is `agy` (flair#1209).
401
689
  bin: "agy",
690
+ kind: "mcp",
402
691
  wire: _wireAntigravity,
403
692
  },
693
+ {
694
+ id: "pi",
695
+ label: "pi",
696
+ bin: "pi",
697
+ // NOT an MCP client — pi loads @tpsdev-ai/pi-flair as a native extension
698
+ // via its settings.json `packages` key (flair#1342). Consumers doing
699
+ // MCP-shaped work must filter on `kind`.
700
+ kind: "native-extension",
701
+ // pi is also detected by its settings file: a configured pi whose binary
702
+ // isn't on THIS shell's PATH (version manager, launchd context) is still
703
+ // a pi whose wiring is worth checking/fixing. Pure fs check, both legs.
704
+ detect: () => detectBin("pi") || existsSync(piSettingsPath()),
705
+ wire: _wirePi,
706
+ },
404
707
  ];
405
708
  /**
406
709
  * The summary `flair init` prints LAST.
@@ -455,14 +758,17 @@ export function renderWiringSummary(results, opts = {}) {
455
758
  return lines;
456
759
  }
457
760
  /**
458
- * Detect every known client. One rule (`bin` on PATH) applied uniformly, so a
459
- * client added to ALL_CLIENTS is detected by declaring its executable there is
460
- * no per-client branch here to forget to extend.
761
+ * Detect every known client. One rule (`bin` on PATH) applied uniformly a
762
+ * client added to ALL_CLIENTS is detected by declaring its executable, with no
763
+ * per-client branch here to forget to extend. A client may widen that with a
764
+ * declared `detect` override (still a pure fs check — pi adds its settings
765
+ * file as a second signal, flair#1342); the override lives on the registry
766
+ * entry, so this function stays branch-free.
461
767
  */
462
768
  export function detectClients() {
463
769
  return ALL_CLIENTS.map((client) => ({
464
770
  ...client,
465
- detected: detectBin(client.bin),
771
+ detected: client.detect ? client.detect() : detectBin(client.bin),
466
772
  }));
467
773
  }
468
774
  export function wireClaudeCode(env) {
@@ -480,3 +786,6 @@ export function wireCursor(env) {
480
786
  export function wireAntigravity(env) {
481
787
  return _wireAntigravity(env);
482
788
  }
789
+ export function wirePi(env) {
790
+ return _wirePi(env);
791
+ }
@@ -119,6 +119,34 @@ export function readAdminPassFileSecure(path) {
119
119
  export function defaultAdminPassPath() {
120
120
  return join(homedir(), ".flair", "admin-pass");
121
121
  }
122
+ /** The admin username Harper's bootstrap creates and every Basic-auth path
123
+ * historically hardcoded. Kept as the default; overridable per call via
124
+ * `resolveAdminUser` (flair#1345). */
125
+ export const DEFAULT_ADMIN_USER = "admin";
126
+ /**
127
+ * Resolve the admin USERNAME for Basic auth against a Flair/Harper instance
128
+ * (flair#1345).
129
+ *
130
+ * Precedence: explicit value (an `--admin-user` flag) → `FLAIR_ADMIN_USER`
131
+ * env → `"admin"`. Always returns a usable name — unlike the password there
132
+ * is no "missing" state, because `admin` is the correct answer for every
133
+ * instance `flair init` ever bootstrapped.
134
+ *
135
+ * Why it exists: with `authorizeLocal: false` (the #604/#610 hardening) the
136
+ * CLI must send real Basic admin auth, and on an instance whose superuser is
137
+ * not named `admin` there was no way to say so — `--admin-pass` existed,
138
+ * the username didn't. A wrong username and a wrong password produce the
139
+ * SAME 401 "Login failed" from Harper, so this also feeds the 401 hint text
140
+ * (see opsAuth401Hint in src/cli.ts).
141
+ *
142
+ * Unlike the admin PASSWORD (see resolveLocalAdminPass's remote-target
143
+ * guard), the env leg is honored for remote targets too: a username is not a
144
+ * secret — sending the wrong one to a third-party host leaks nothing and
145
+ * fails closed with a 401.
146
+ */
147
+ export function resolveAdminUser(explicit) {
148
+ return explicit || process.env.FLAIR_ADMIN_USER || DEFAULT_ADMIN_USER;
149
+ }
122
150
  export function defaultKeysDir() {
123
151
  return join(homedir(), ".flair", "keys");
124
152
  }
@@ -400,10 +428,13 @@ export async function tryAgentKeyFloor(baseUrl, method, path, body, keysDir) {
400
428
  export async function authedRequest(method, path, body, opts) {
401
429
  const isLocal = opts.isLocal ?? isLocalBase(opts.baseUrl);
402
430
  const keysDir = opts.keysDir ?? defaultKeysDir();
431
+ // One username for every Basic tier: flag > FLAIR_ADMIN_USER env > "admin"
432
+ // (flair#1345 — these three sites used to hardcode the literal `admin`).
433
+ const adminUser = resolveAdminUser(opts.adminUser);
403
434
  let authHeader;
404
435
  // Tier 1: explicit — caller-resolved flag material always wins.
405
436
  if (opts.explicitAdminPass) {
406
- authHeader = `Basic ${Buffer.from(`admin:${opts.explicitAdminPass}`).toString("base64")}`;
437
+ authHeader = `Basic ${Buffer.from(`${adminUser}:${opts.explicitAdminPass}`).toString("base64")}`;
407
438
  }
408
439
  else if (opts.explicitKeyPath && opts.agentId) {
409
440
  try {
@@ -421,7 +452,7 @@ export async function authedRequest(method, path, body, opts) {
421
452
  }
422
453
  else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
423
454
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
424
- authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
455
+ authHeader = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
425
456
  }
426
457
  }
427
458
  // Tier 3: a PINNED agent identity — sign specifically as this agent via
@@ -444,7 +475,7 @@ export async function authedRequest(method, path, body, opts) {
444
475
  try {
445
476
  const filePass = resolveLocalAdminPass(undefined, !isLocal);
446
477
  if (filePass) {
447
- authHeader = `Basic ${Buffer.from(`admin:${filePass}`).toString("base64")}`;
478
+ authHeader = `Basic ${Buffer.from(`${adminUser}:${filePass}`).toString("base64")}`;
448
479
  }
449
480
  }
450
481
  catch (err) {