@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.
- package/README.md +2 -0
- package/dist/build-info.json +3 -3
- package/dist/cli.js +525 -121
- package/dist/component-env.js +52 -4
- package/dist/doctor-client.js +46 -1
- package/dist/hook-install.js +52 -4
- package/dist/install/clients.js +318 -9
- package/dist/lib/auth-resolve.js +34 -3
- package/dist/lib/mcp-enable.js +134 -26
- package/dist/resources/AgentSeed.js +2 -0
- package/dist/resources/Memory.js +24 -5
- package/dist/resources/MemoryFeed.js +3 -0
- package/dist/resources/MemoryMaintenance.js +11 -2
- package/dist/resources/bm25-index-service.js +257 -0
- package/dist/resources/bm25-index.js +631 -0
- package/dist/resources/bm25.js +31 -1
- package/dist/resources/embeddings-boot.js +45 -3
- package/dist/resources/memory-read-scope.js +2 -0
- package/dist/resources/semantic-retrieval-core.js +93 -22
- package/dist/version-check.js +59 -13
- package/docs/claude-code.md +10 -3
- package/docs/deployment.md +11 -1
- package/docs/integrations.md +25 -4
- package/docs/mcp-clients.md +18 -0
- package/docs/notes/mcp-oauth-model2.md +31 -13
- package/docs/quickstart.md +9 -9
- package/docs/standalone-local.md +3 -0
- package/package.json +3 -2
package/dist/component-env.js
CHANGED
|
@@ -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
|
-
*
|
|
222
|
-
* the
|
|
223
|
-
*
|
|
224
|
-
* to
|
|
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",
|
package/dist/doctor-client.js
CHANGED
|
@@ -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
|
package/dist/hook-install.js
CHANGED
|
@@ -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`.
|
|
99
|
-
*
|
|
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
|
|
102
|
-
const
|
|
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
|
}
|
package/dist/install/clients.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// ─── Client Detection & Wiring ──────────────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
// Detects locally installed
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
459
|
-
* client added to ALL_CLIENTS is detected by declaring its executable
|
|
460
|
-
*
|
|
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
|
+
}
|
package/dist/lib/auth-resolve.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
478
|
+
authHeader = `Basic ${Buffer.from(`${adminUser}:${filePass}`).toString("base64")}`;
|
|
448
479
|
}
|
|
449
480
|
}
|
|
450
481
|
catch (err) {
|