@tpsdev-ai/flair 0.49.0 → 0.50.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 CHANGED
@@ -26,7 +26,7 @@ import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion
26
26
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
27
27
  import { readClientMcpBlock, effectiveFlairUrl, checkPiFlairWiring, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
28
28
  import { checkGlobalBinOnPath, cliBootPathWarning, resolveNpmGlobalPrefix, } from "./install/global-bin-path.js";
29
- import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
29
+ import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookSettingsPath, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "./hook-install.js";
30
30
  import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, DEFAULT_ADMIN_USER, resolveAdminUser, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
31
31
  import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
32
32
  import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
@@ -685,6 +685,48 @@ function resolveSigningIdentityFor(opts, command) {
685
685
  function resolveSigningAgentId(opts, command) {
686
686
  return resolveSigningIdentityFor(opts, command).agentId;
687
687
  }
688
+ // ── Shared credential/identity flag surface (flair#1106) ─────────────────────
689
+ // Sibling commands (memory add, backup, federation sync) used to drift on
690
+ // the same concepts: `--admin-pass-file` existed on backup/sync but was an
691
+ // unknown option on `memory add`, and `memory add --agent` was a commander
692
+ // requiredOption so FLAIR_AGENT_ID could never satisfy it. One helper owns
693
+ // the credential flag names/shapes; identity (`--agent`) stays optional so
694
+ // the env fallback can actually apply. This does not invent a new auth
695
+ // model — it only declares the flags authedRequest already resolves.
696
+ /** Flag strings the sibling commands must share (name + argument shape). */
697
+ export const SHARED_CREDENTIAL_FLAGS = {
698
+ adminPass: "--admin-pass <pass>",
699
+ adminPassFile: "--admin-pass-file <path>",
700
+ adminUser: "--admin-user <name>",
701
+ };
702
+ export const SHARED_IDENTITY_FLAGS = {
703
+ agent: "--agent <id>",
704
+ };
705
+ function addSharedCredentialOptions(cmd) {
706
+ return cmd
707
+ .option(SHARED_CREDENTIAL_FLAGS.adminPass, "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
708
+ .option(SHARED_CREDENTIAL_FLAGS.adminPassFile, "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
709
+ .option(SHARED_CREDENTIAL_FLAGS.adminUser, "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)");
710
+ }
711
+ function addSharedIdentityOption(cmd) {
712
+ return cmd.option(SHARED_IDENTITY_FLAGS.agent, "Agent ID (or set FLAIR_AGENT_ID env)");
713
+ }
714
+ /**
715
+ * Resolve `--admin-pass-file` into the same `adminPass` slot the inline flag
716
+ * uses. Shared so sibling commands cannot drift on how the file is read
717
+ * (mode 0600 via readAdminPassFileSecure).
718
+ */
719
+ function applyAdminPassFile(opts) {
720
+ if (!opts.adminPass && opts.adminPassFile) {
721
+ try {
722
+ opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
723
+ }
724
+ catch (err) {
725
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
726
+ process.exit(1);
727
+ }
728
+ }
729
+ }
688
730
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
689
731
  //
690
732
  // Deliberately NOT routed through Harper's per-instance config the way
@@ -1429,7 +1471,13 @@ async function api(method, path, body, options) {
1429
1471
  agentId = decodeURIComponent(match[1]);
1430
1472
  }
1431
1473
  }
1432
- return authedRequest(method, path, body, { baseUrl: base, agentId, keysDir: options?.keysDir });
1474
+ return authedRequest(method, path, body, {
1475
+ baseUrl: base,
1476
+ agentId,
1477
+ keysDir: options?.keysDir,
1478
+ explicitAdminPass: options?.explicitAdminPass,
1479
+ adminUser: options?.adminUser,
1480
+ });
1433
1481
  }
1434
1482
  /**
1435
1483
  * The authedGet `flair upgrade` verification (flair#635/#741) hands to
@@ -2614,9 +2662,9 @@ export function upgradeStatusSuffix(name, status) {
2614
2662
  * is `flair doctor --fix`, never `npm install -g`.
2615
2663
  * 3. Wired with a concrete pin — that pin IS the installed version
2616
2664
  * (current when it equals latest, else outdated → re-pin via doctor).
2617
- * 4. Wired but unpinned (a bare npx spec / the SessionStart hook) — `npx -y`
2618
- * re-resolves latest every session, so the effective version IS latest →
2619
- * current.
2665
+ * 4. Wired but unpinned (a bare npx spec / a pre-#1143 SessionStart hook) —
2666
+ * `npx -y` re-resolves latest every session, so the effective version IS
2667
+ * latest → current.
2620
2668
  */
2621
2669
  export function resolveFlairMcpFinding(globalProbe, latest, wiring) {
2622
2670
  // 1. Legacy global install.
@@ -4573,18 +4621,12 @@ keys
4573
4621
  // dry-run delta, symmetric removal) lives in src/hook-install.ts — this
4574
4622
  // section is pure CLI plumbing: option parsing, default resolution, and
4575
4623
  // rendering the pure functions' results.
4576
- function resolveHookAgentId(opts, homeDir) {
4577
- return (opts.agent ||
4578
- opts.agentId ||
4579
- process.env.FLAIR_AGENT_ID ||
4580
- readClientMcpBlock("claude-code", homeDir).agentId ||
4581
- undefined);
4582
- }
4583
- function resolveHookFlairUrl(opts, homeDir) {
4624
+ function resolveHookFlairUrl(opts, homeDir, harness) {
4584
4625
  return (opts.url ||
4585
4626
  process.env.FLAIR_TARGET ||
4586
4627
  process.env.FLAIR_URL ||
4587
- readClientMcpBlock("claude-code", homeDir).flairUrl ||
4628
+ readClientMcpBlock(harness, homeDir).flairUrl ||
4629
+ (harness !== "claude-code" ? readClientMcpBlock("claude-code", homeDir).flairUrl : undefined) ||
4588
4630
  resolveBaseUrl({}));
4589
4631
  }
4590
4632
  function requireSupportedHarness(raw) {
@@ -4601,19 +4643,19 @@ hook
4601
4643
  .description("Wire the Flair SessionStart hook into the harness config so memory loads automatically at session start")
4602
4644
  .option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
4603
4645
  .option("--dry-run", "Print the exact JSON delta without writing")
4604
- .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
4646
+ .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for this harness's MCP client)")
4605
4647
  .option("--agent-id <id>", "Alias for --agent")
4606
- .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else the existing claude-code MCP wiring, else the local default)")
4648
+ .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else this harness's MCP wiring, else the local default)")
4607
4649
  .option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
4608
4650
  .action((opts) => {
4609
4651
  const harness = requireSupportedHarness(opts.harness);
4610
4652
  const home = homedir();
4611
- const agentId = resolveHookAgentId(opts, home);
4653
+ const agentId = resolveHookAgentId(opts, home, harness);
4612
4654
  if (!agentId) {
4613
4655
  console.error("No agent id known — pass --agent <id>, set FLAIR_AGENT_ID, or run `flair init` / `flair agent add` first.");
4614
4656
  process.exit(1);
4615
4657
  }
4616
- const flairUrl = resolveHookFlairUrl(opts, home);
4658
+ const flairUrl = resolveHookFlairUrl(opts, home, harness);
4617
4659
  const dryRun = !!opts.dryRun;
4618
4660
  if (opts.continuity) {
4619
4661
  const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
@@ -4630,6 +4672,11 @@ hook
4630
4672
  const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
4631
4673
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4632
4674
  console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
4675
+ const pinWarning = unpinnedSpecWarning();
4676
+ if (pinWarning && result.ok) {
4677
+ for (const line of pinWarning.split("\n"))
4678
+ console.error(` ⚠ ${line}`);
4679
+ }
4633
4680
  if (result.backupPath) {
4634
4681
  console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
4635
4682
  }
@@ -4689,16 +4736,20 @@ hook
4689
4736
  // status in every branch below. "absent" is NOT a failure: installing the
4690
4737
  // pair is the opt-in, so absence renders as "not enabled".
4691
4738
  const renderContinuity = () => {
4739
+ // Continuity is Claude Code only. Do not tip `--continuity --harness
4740
+ // <other>` — that writes Claude tool matchers into the wrong file.
4741
+ if (!harnessSupportsContinuity(harness))
4742
+ return;
4692
4743
  const cont = continuityHookStatus(home, harness);
4693
4744
  if (cont.state === "installed") {
4694
4745
  console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
4695
4746
  }
4696
4747
  else if (cont.state === "absent") {
4697
- console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, "(opt-in: flair hook install --continuity)")}`);
4748
+ console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, `(opt-in: ${hookInstallHint(harness, "--continuity")})`)}`);
4698
4749
  }
4699
4750
  else {
4700
4751
  const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
4701
- console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, "— re-run: flair hook install --continuity")}`);
4752
+ console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, `— re-run: ${hookInstallHint(harness, "--continuity")}`)}`);
4702
4753
  }
4703
4754
  };
4704
4755
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
@@ -4711,7 +4762,7 @@ hook
4711
4762
  }
4712
4763
  if (!status.wired) {
4713
4764
  console.log(` ${render.icons.error} not wired`);
4714
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
4765
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${hookInstallHint(status.harness)}`);
4715
4766
  renderContinuity();
4716
4767
  console.log("");
4717
4768
  process.exit(1);
@@ -4732,7 +4783,7 @@ hook
4732
4783
  console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
4733
4784
  }
4734
4785
  else {
4735
- console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`flair hook install\` to adopt the silent form`);
4786
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`${hookInstallHint(status.harness)}\` to adopt the silent form`);
4736
4787
  }
4737
4788
  renderContinuity();
4738
4789
  console.log("");
@@ -6116,6 +6167,95 @@ function driverCheckAppliesTo(opts) {
6116
6167
  const target = resolveTarget(opts);
6117
6168
  return !target || isLocalBase(target.replace(/\/$/, ""));
6118
6169
  }
6170
+ /**
6171
+ * flair#1108: a bare undici/Node "fetch failed" names neither the URL
6172
+ * that was probed nor the knob that would change it. These helpers are
6173
+ * the operator-facing sentence and the setting that produced (or would
6174
+ * change) that URL. Pure so the contract can be unit-tested without
6175
+ * driving process.exit.
6176
+ */
6177
+ export function federationStatusUrlSetting(opts) {
6178
+ if (opts.target)
6179
+ return "--target";
6180
+ if (process.env.FLAIR_TARGET)
6181
+ return "FLAIR_TARGET";
6182
+ if (process.env.FLAIR_URL)
6183
+ return "FLAIR_URL";
6184
+ if (opts.port !== undefined && opts.port !== null && String(opts.port) !== "")
6185
+ return "--port";
6186
+ return "FLAIR_URL or --port";
6187
+ }
6188
+ export function describeFederationStatusFetchFailed(url, setting) {
6189
+ return `fetch failed against ${url} (set ${setting})`;
6190
+ }
6191
+ /** True for a connect-level failure (no HTTP status): Node's undici
6192
+ * `TypeError: fetch failed`, Bun's `Unable to connect…`, or a cause
6193
+ * carrying a connect/DNS errno. Auth and HTTP errors stay out. */
6194
+ export function isFederationStatusConnectFailure(err) {
6195
+ const msg = err instanceof Error ? err.message : String(err);
6196
+ if (/\bfetch failed\b/i.test(msg))
6197
+ return true;
6198
+ if (/unable to connect/i.test(msg))
6199
+ return true;
6200
+ const cause = err instanceof Error ? err.cause : undefined;
6201
+ const code = cause && typeof cause === "object" && cause && "code" in cause
6202
+ ? String(cause.code)
6203
+ : "";
6204
+ return /^(ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH)$/.test(code);
6205
+ }
6206
+ export function rewriteFederationStatusFetchFailed(err, url, setting) {
6207
+ if (!isFederationStatusConnectFailure(err))
6208
+ return err;
6209
+ const next = new Error(describeFederationStatusFetchFailed(url, setting));
6210
+ if (err && typeof err === "object" && "status" in err) {
6211
+ next.status = err.status;
6212
+ }
6213
+ return next;
6214
+ }
6215
+ /**
6216
+ * Auth-shaped vs connect-level for `federation status`. A rewritten
6217
+ * fetch-failed sentence embeds the probed URL; that URL can contain a
6218
+ * whole-token `401` (e.g. `--port 401`). The old `message.includes("401")`
6219
+ * check then printed the credential remedy and hid the URL+setting this
6220
+ * change exists to surface (Bugbot on flair#1108).
6221
+ */
6222
+ export function isFederationStatusAuthFailure(err) {
6223
+ if (!err)
6224
+ return false;
6225
+ if (isFederationStatusConnectFailure(err))
6226
+ return false;
6227
+ if (typeof err === "object" && "status" in err) {
6228
+ const status = err.status;
6229
+ if (status === 401 || status === 403)
6230
+ return true;
6231
+ }
6232
+ const m = err instanceof Error
6233
+ ? err.message
6234
+ : String(typeof err === "object" && err && "message" in err
6235
+ ? err.message ?? err
6236
+ : err);
6237
+ return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
6238
+ }
6239
+ /**
6240
+ * Whether to print the "set one of: FLAIR_AGENT_ID / FLAIR_ADMIN_PASS /
6241
+ * FLAIR_TOKEN" block. Narrower than `isFederationStatusAuthFailure`: a
6242
+ * 403 with credentials already sent (wrong password) is fatal, but the
6243
+ * server's own body is the honest message — the credential-list remedy
6244
+ * is for missing/invalid auth (401), not a rejected password (flair#634).
6245
+ */
6246
+ export function isFederationStatusAuthRemedy(err) {
6247
+ if (!err || isFederationStatusConnectFailure(err))
6248
+ return false;
6249
+ if (typeof err === "object" && "status" in err && err.status === 401) {
6250
+ return true;
6251
+ }
6252
+ const m = err instanceof Error
6253
+ ? err.message
6254
+ : String(typeof err === "object" && err && "message" in err
6255
+ ? err.message ?? err
6256
+ : err);
6257
+ return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
6258
+ }
6119
6259
  federation
6120
6260
  .command("status")
6121
6261
  .description("Show federation status and peer connections")
@@ -6124,8 +6264,11 @@ federation
6124
6264
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
6125
6265
  .option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
6126
6266
  .action(async (opts) => {
6127
- const target = resolveTarget(opts);
6128
- const baseUrl = target ? target.replace(/\/$/, "") : undefined;
6267
+ // Same URL api() would have derived, including --port (the command
6268
+ // advertised --port but previously dropped it on the floor). Naming
6269
+ // that URL on fetch failure is only honest if it is the URL we probe.
6270
+ const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
6271
+ const urlSetting = federationStatusUrlSetting(opts);
6129
6272
  const mode = render.resolveOutputMode(opts);
6130
6273
  // flair#1233: fetch instance and peers INDEPENDENTLY. One read failing
6131
6274
  // must never take down the whole render — the principle latestPeerContact
@@ -6135,41 +6278,33 @@ federation
6135
6278
  let instance = null;
6136
6279
  let instanceErr = null;
6137
6280
  try {
6138
- instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
6281
+ instance = await api("GET", "/FederationInstance", undefined, { baseUrl });
6139
6282
  }
6140
6283
  catch (err) {
6141
- instanceErr = err;
6284
+ instanceErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
6142
6285
  }
6143
6286
  // peers: null = unverifiable (the read failed), [] = verified empty.
6144
6287
  let peers = null;
6145
6288
  let peersErr = null;
6146
6289
  try {
6147
- const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
6290
+ const r = await api("GET", "/FederationPeers", undefined, { baseUrl });
6148
6291
  peers = r.peers ?? [];
6149
6292
  }
6150
6293
  catch (err) {
6151
- peersErr = err;
6294
+ peersErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
6152
6295
  }
6153
6296
  // Auth-shaped failures stay FATAL even when the other read succeeded:
6154
6297
  // both endpoints sit behind the same allowAdmin gate, so a 401/403 is a
6155
6298
  // property of the session's credentials, not of one endpoint — and
6156
6299
  // degrading it to "unverifiable" would swallow the actionable remedy
6157
6300
  // (flair#634's UX, kept). Only non-auth failures degrade independently.
6158
- const authShaped = (err) => {
6159
- if (!err)
6160
- return false;
6161
- if (err.status === 401 || err.status === 403)
6162
- return true;
6163
- const m = String(err.message ?? err);
6164
- return m.includes("missing_or_invalid_authorization") || m.includes("401");
6165
- };
6166
6301
  // Both reads failed → nothing to render at all. Either way keep the
6167
6302
  // classic failure UX (auth remedy when it's an auth problem), exit
6168
6303
  // non-zero.
6169
- if ((instanceErr && peersErr) || authShaped(instanceErr) || authShaped(peersErr)) {
6304
+ if ((instanceErr && peersErr) || isFederationStatusAuthFailure(instanceErr) || isFederationStatusAuthFailure(peersErr)) {
6170
6305
  const primaryErr = instanceErr ?? peersErr;
6171
6306
  const msg = String(primaryErr.message ?? primaryErr);
6172
- if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
6307
+ if (isFederationStatusAuthRemedy(primaryErr)) {
6173
6308
  console.error(`${render.icons.error} federation status requires auth.`);
6174
6309
  console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
6175
6310
  console.error(` ${render.wrap(render.c.cyan, "FLAIR_AGENT_ID=<your-agent-id>")} ${render.wrap(render.c.dim, "(Ed25519 — uses ~/.flair/keys/<id>.key)")}`);
@@ -7004,29 +7139,16 @@ export async function runFederationSyncOnce(opts) {
7004
7139
  return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
7005
7140
  }
7006
7141
  }
7007
- const federationSync = federation
7142
+ const federationSync = addSharedCredentialOptions(federation
7008
7143
  .command("sync")
7009
7144
  .description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
7010
7145
  .option("--port <port>", "Harper HTTP port")
7011
- .option("--admin-pass <pass>", "Admin password")
7012
- .option("--admin-pass-file <path>", "Read the admin password from a file (e.g. ~/.flair/admin-pass). Preferred for launchd/cron — keeps the secret out of ps and shell history.")
7013
- .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
7014
7146
  .option("--ops-port <port>", "Harper operations API port")
7015
7147
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
7016
- .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
7017
- .action(async (opts) => {
7148
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")).action(async (opts) => {
7018
7149
  // --admin-pass-file resolves into the same `adminPass` slot the inline
7019
7150
  // flag uses, so the scheduler never has to embed a secret in a unit file.
7020
- // readAdminPassFileSecure() refuses a file that is not owner-only.
7021
- if (!opts.adminPass && opts.adminPassFile) {
7022
- try {
7023
- opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
7024
- }
7025
- catch (err) {
7026
- console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
7027
- process.exit(1);
7028
- }
7029
- }
7151
+ applyAdminPassFile(opts);
7030
7152
  const r = await runFederationSyncOnce(opts);
7031
7153
  if (r.error) {
7032
7154
  console.error(`Error: ${r.error.message}`);
@@ -12918,8 +13040,8 @@ program
12918
13040
  // Antigravity) the MCP block present + reachable + the configured agent
12919
13041
  // genuinely registered; for pi (a NATIVE EXTENSION host — flair#1342) the
12920
13042
  // pi-flair reference in pi's own settings, including the flair#1346
12921
- // npm:-under-"extensions" trap; plus CLAUDE.md + the SessionStart hook
12922
- // (Claude Code only, since only Claude Code has those mechanisms). Reuses
13043
+ // npm:-under-"extensions" trap; plus CLAUDE.md (Claude Code) and the
13044
+ // SessionStart hook (Claude Code + Codex flair#1148). Reuses
12923
13045
  // detectClients() rather than reimplementing client detection.
12924
13046
  console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
12925
13047
  // Prompt y/N before a content-editing fix, but only when interactive —
@@ -12940,6 +13062,7 @@ program
12940
13062
  }
12941
13063
  else {
12942
13064
  let claudeCodeAgentId;
13065
+ let codexAgentId;
12943
13066
  let anyKnownAgentId;
12944
13067
  // `doctor --fix` writes client configs through the same wire functions
12945
13068
  // init does, so it owes the user the same warning when the spec it would
@@ -13119,6 +13242,8 @@ program
13119
13242
  const block = readClientMcpBlock(client.id, homedir());
13120
13243
  if (client.id === "claude-code" && block.agentId)
13121
13244
  claudeCodeAgentId = block.agentId;
13245
+ if (client.id === "codex" && block.agentId)
13246
+ codexAgentId = block.agentId;
13122
13247
  if (block.agentId)
13123
13248
  anyKnownAgentId = anyKnownAgentId ?? block.agentId;
13124
13249
  if (!block.present) {
@@ -13165,8 +13290,14 @@ program
13165
13290
  client.id === "antigravity" ? wireAntigravity(wireEnv) :
13166
13291
  wireCursor(wireEnv);
13167
13292
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
13168
- if (wireResult.ok)
13293
+ if (wireResult.ok) {
13169
13294
  fixed++;
13295
+ if (client.id === "claude-code")
13296
+ claudeCodeAgentId = fixAgentId;
13297
+ if (client.id === "codex")
13298
+ codexAgentId = fixAgentId;
13299
+ anyKnownAgentId = anyKnownAgentId ?? fixAgentId;
13300
+ }
13170
13301
  }
13171
13302
  }
13172
13303
  }
@@ -13216,8 +13347,9 @@ program
13216
13347
  console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
13217
13348
  }
13218
13349
  }
13219
- // Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
13220
- // has these mechanisms, so only run them when claude-code was detected.
13350
+ // Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
13351
+ // Codex has a SessionStart hook too (checked below); CLAUDE.md and
13352
+ // continuity stay Claude Code only.
13221
13353
  if (detectedClients.some((c) => c.id === "claude-code")) {
13222
13354
  const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
13223
13355
  if (claudeMd.present) {
@@ -13407,6 +13539,92 @@ program
13407
13539
  issues++;
13408
13540
  }
13409
13541
  }
13542
+ // Codex SessionStart hook (flair#1148) — same flair-session-start
13543
+ // command Claude Code uses, written to ~/.codex/hooks.json. Continuity
13544
+ // and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
13545
+ // is the hook file.
13546
+ if (detectedClients.some((c) => c.id === "codex")) {
13547
+ const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
13548
+ if (hook.present) {
13549
+ if (hook.execution === "broken") {
13550
+ if (hook.silenced) {
13551
+ console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
13552
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
13553
+ console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
13554
+ console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Codex session will warm the npx cache.")}`);
13555
+ console.log(` ${render.wrap(render.c.dim, "Codex requires /hooks to trust a newly written command before it runs.")}`);
13556
+ }
13557
+ else {
13558
+ console.log(` ${render.icons.warn} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
13559
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
13560
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(rewrites the hook to the current silent-failure form)")}`);
13561
+ }
13562
+ }
13563
+ else if (hook.execution === "unknown") {
13564
+ console.log(` ${render.icons.warn} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
13565
+ }
13566
+ else if (!hook.ours) {
13567
+ console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "(custom command — not verified, not modified)")}`);
13568
+ }
13569
+ else {
13570
+ console.log(` ${render.icons.ok} SessionStart hook (codex): flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
13571
+ }
13572
+ if (!hook.silenced && hook.ours) {
13573
+ console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
13574
+ if (hook.upgradable) {
13575
+ if (autoFix) {
13576
+ if (dryRun) {
13577
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
13578
+ }
13579
+ else {
13580
+ const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
13581
+ if (!proceed) {
13582
+ console.log(` Skipped.`);
13583
+ }
13584
+ else {
13585
+ const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
13586
+ console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
13587
+ if (upgrade.ok && upgrade.changed)
13588
+ fixed++;
13589
+ }
13590
+ }
13591
+ }
13592
+ else {
13593
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(rewrites the hook command in place — same agent, same instance)")}`);
13594
+ }
13595
+ }
13596
+ else {
13597
+ console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install --harness codex`);
13598
+ }
13599
+ issues++;
13600
+ }
13601
+ }
13602
+ else {
13603
+ console.log(` ${render.icons.error} SessionStart hook (codex): not found in ${render.wrap(render.c.dim, hook.path)}`);
13604
+ if (autoFix) {
13605
+ if (dryRun) {
13606
+ console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
13607
+ }
13608
+ else {
13609
+ const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
13610
+ if (!proceed) {
13611
+ console.log(` Skipped.`);
13612
+ }
13613
+ else {
13614
+ const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
13615
+ const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
13616
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13617
+ if (fixRes.ok)
13618
+ fixed++;
13619
+ }
13620
+ }
13621
+ }
13622
+ else {
13623
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
13624
+ }
13625
+ issues++;
13626
+ }
13627
+ }
13410
13628
  }
13411
13629
  // 7a. Resolve which agent identities the two verified-read sections below
13412
13630
  // (Fleet presence, Migrations) iterate (flair#722). Previously both
@@ -13806,7 +14024,8 @@ program
13806
14024
  // from /HealthDetail at all — it's the one metric in this file that requires
13807
14025
  // live QUERIES, because it's checking whether querying itself still works.
13808
14026
  // For a sample of the querying agent's OWN memories (fetchRecallSpotCheckData
13809
- // below, GET /Memory?agentId=<self>), a CUE is derived from each memory
14027
+ // below, a projected+bounded GET /Memory flair#1360: never the unfiltered
14028
+ // collection with embeddings inline), a CUE is derived from each memory
13810
14029
  // (deriveRecallCue — its `subject` if present, else the leading ~8 words /
13811
14030
  // first sentence of `content`; a PARTIAL cue, never the full content) and
13812
14031
  // searched for through the EXACT SAME authenticated read path `flair memory
@@ -13869,6 +14088,53 @@ export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
13869
14088
  * "first-pass default, tunable later" spirit as the thresholds above. */
13870
14089
  export const QUALITY_RECALL_SAMPLE_SIZE = 10;
13871
14090
  export const QUALITY_RECALL_K = 5;
14091
+ /**
14092
+ * Fields the recall spot-check and the quality-snapshot lookup actually
14093
+ * read. Harper REST `select(...)` (same syntax adk-flair-js's listMemories
14094
+ * already uses) projects these server-side so the nightly sweep never
14095
+ * pulls embedding vectors inline — the defect in flair#1360 was an
14096
+ * unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
14097
+ * vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
14098
+ * 10 memories. `type` is intentionally omitted: it is not a declared
14099
+ * Memory column (see schemas/memory.graphql); snapshot exclusion keys
14100
+ * off `subject` (`quality-snapshot/…`).
14101
+ */
14102
+ export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
14103
+ /**
14104
+ * Extra most-recent rows fetched beyond `sampleSize` so
14105
+ * `planRecallSpotCheck` can drop the sweep's own quality-snapshot
14106
+ * bookkeeping and still fill a 10-row window — without scanning the
14107
+ * table. Nightly `--emit` writes one snapshot per run; 16 is a buffer
14108
+ * for a few extra `--emit`s in the same recency window, not a second
14109
+ * full-table read.
14110
+ */
14111
+ export const QUALITY_RECALL_SNAPSHOT_OVERFETCH = 16;
14112
+ /**
14113
+ * Harper REST collection path for the recall spot-check's sample fetch:
14114
+ * agent-scoped, projected (never `embedding`), recency-sorted, bounded.
14115
+ * `limit(start,end)` is Harper's offset window — same as
14116
+ * packages/adk-flair-js/src/memory_service.ts.
14117
+ */
14118
+ export function qualityRecallSamplePath(agentId, sampleSize = QUALITY_RECALL_SAMPLE_SIZE) {
14119
+ const select = QUALITY_MEMORY_LIST_SELECT.join(",");
14120
+ const end = sampleSize + QUALITY_RECALL_SNAPSHOT_OVERFETCH;
14121
+ return `/Memory?agentId=${encodeURIComponent(agentId)}&select(${select})&sort(-createdAt)&limit(0,${end})`;
14122
+ }
14123
+ /**
14124
+ * Harper REST collection path for the previous quality-snapshot lookup:
14125
+ * same projection as the sample fetch (never `embedding`). Subject is
14126
+ * passed as a query equals (indexed) plus a client-side re-filter —
14127
+ * Memory.search() historically did not turn bare query params into
14128
+ * conditions beyond the signed agent scope, so the client-side filter
14129
+ * in fetchPreviousQualitySnapshot stays as defense in depth. No `limit`:
14130
+ * a bounded window could miss yesterday's snapshot after a busy day of
14131
+ * writes, and without a reliable server-side subject pushdown that
14132
+ * would silently look like a first run.
14133
+ */
14134
+ export function qualitySnapshotLookupPath(agentId, subject) {
14135
+ const select = QUALITY_MEMORY_LIST_SELECT.join(",");
14136
+ return `/Memory?agentId=${encodeURIComponent(agentId)}&subject=${encodeURIComponent(subject)}&select(${select})&sort(-createdAt)`;
14137
+ }
13872
14138
  /** Leading-word cap on the content-derived cue. 25, matching the arm of the
13873
14139
  * flair#967 A/B that was actually measured (same 10 memories, same instance,
13874
14140
  * same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
@@ -14230,23 +14496,25 @@ export function computeQualityReport(healthy, healthData, opts = {}) {
14230
14496
  * `agentId`'s own memories and, for each, search for a cue derived from it.
14231
14497
  * Reuses the EXACT read path `flair memory search` / `flair memory list`
14232
14498
  * use — `api()` (→ authedRequest's 5-tier resolver) for both the
14233
- * `GET /Memory?agentId=...` sample fetch and the `POST /SemanticSearch`
14234
- * queriesso this has zero new endpoint and zero new auth mechanism; it
14235
- * is scoped to `agentId`'s own memories exactly as those commands already
14236
- * are. Never throws: every failure mode (no agentId, fewer than
14237
- * `sampleSize` memories, a fetch/search error) returns `{ ok: false,
14238
- * skipReason }` for computeQualityReport to turn into a `gaps` entry.
14499
+ * projected, bounded `GET /Memory?…&select(…)&limit(…)` sample fetch
14500
+ * (flair#1360never the unfiltered collection with embeddings inline)
14501
+ * and the `POST /SemanticSearch` queries so this has zero new endpoint
14502
+ * and zero new auth mechanism; it is scoped to `agentId`'s own memories
14503
+ * exactly as those commands already are. Never throws: every failure mode
14504
+ * (no agentId, fewer than `sampleSize` memories, a fetch/search error)
14505
+ * returns `{ ok: false, skipReason }` for computeQualityReport to turn
14506
+ * into a `gaps` entry.
14239
14507
  */
14240
- async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14508
+ export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14241
14509
  const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
14242
14510
  const k = opts.k ?? QUALITY_RECALL_K;
14511
+ const request = opts.request ?? api;
14243
14512
  if (!agentId) {
14244
14513
  return { ok: false, skipReason: "no agent identity to query as — pass --agent or set FLAIR_AGENT_ID" };
14245
14514
  }
14246
14515
  let all;
14247
14516
  try {
14248
- const q = new URLSearchParams({ agentId }).toString();
14249
- const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
14517
+ const raw = await request("GET", qualityRecallSamplePath(agentId, sampleSize), undefined, { baseUrl, agentId });
14250
14518
  all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
14251
14519
  }
14252
14520
  catch (err) {
@@ -14276,7 +14544,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14276
14544
  try {
14277
14545
  for (const { id, cue } of plan.sampled) {
14278
14546
  const body = { agentId, q: cue, limit: k };
14279
- const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
14547
+ const res = await request("POST", "/SemanticSearch", body, { baseUrl, agentId });
14280
14548
  const results = Array.isArray(res) ? res : (res?.results ?? []);
14281
14549
  sampledIds.push(id);
14282
14550
  perQueryResultIds.push(results.map((r) => String(r.id)));
@@ -14494,21 +14762,23 @@ export function qualitySnapshotSubject(baseUrl) {
14494
14762
  return `quality-snapshot/${host}`;
14495
14763
  }
14496
14764
  /** Fetch the most recent prior quality snapshot for `agentId` at `baseUrl`,
14497
- * via the exact same read path fetchRecallSpotCheckData uses (`api("GET",
14498
- * "/Memory?agentId=...")`, self-scoped by the signed request's own agent
14499
- * identity — no new endpoint). Filters client-side by subject (the server's
14500
- * `GET /Memory?...` doesn't translate query params into search conditions
14501
- * beyond the signed agentId scope see resources/Memory.ts's search()),
14502
- * same client-side-filter pattern `memory list --hash-fallback` already
14503
- * uses. Returns null on: no prior snapshot, a fetch error, or a snapshot row
14504
- * whose content isn't parseable/versioned JSON (never throws a corrupt or
14505
- * foreign row degrades to "no snapshot", same as a genuine first run, rather
14506
- * than crashing `--emit`). */
14507
- async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject) {
14765
+ * via the same signed `GET /Memory` read path fetchRecallSpotCheckData
14766
+ * uses (self-scoped by the signed request's own agent identity — no new
14767
+ * endpoint). Projects the same fields (never embeddings — flair#1360) and
14768
+ * asks for `subject` as a query equals; still filters client-side by
14769
+ * subject because Memory.search() historically did not turn bare query
14770
+ * params into search conditions beyond the signed agentId scope (see
14771
+ * resources/Memory.ts's search()), same client-side-filter pattern
14772
+ * `memory list --hash-fallback` already uses. Returns null on: no prior
14773
+ * snapshot, a fetch error, or a snapshot row whose content isn't
14774
+ * parseable/versioned JSON (never throws — a corrupt or foreign row
14775
+ * degrades to "no snapshot", same as a genuine first run, rather than
14776
+ * crashing `--emit`). */
14777
+ export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, opts = {}) {
14778
+ const request = opts.request ?? api;
14508
14779
  let all;
14509
14780
  try {
14510
- const q = new URLSearchParams({ agentId }).toString();
14511
- const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
14781
+ const raw = await request("GET", qualitySnapshotLookupPath(agentId, subject), undefined, { baseUrl, agentId });
14512
14782
  all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
14513
14783
  }
14514
14784
  catch {
@@ -14976,9 +15246,8 @@ function parseEntitiesOptionOrExit(csv) {
14976
15246
  }
14977
15247
  const ENTITIES_OPTION_DESCRIPTION = "Comma-separated entity vocabulary strings this record touches (type:value from the closed type set, e.g. repo:tpsdev-ai/flair — see docs/entity-vocabulary.md; feeds `flair attention`)";
14978
15248
  const memory = program.command("memory").description("Manage agent memories");
14979
- memory.command("add [content]")
14980
- .description("Write a new memory row for an agent (content via positional arg or --content)")
14981
- .requiredOption("--agent <id>")
15249
+ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]")
15250
+ .description("Write a new memory row for an agent (content via positional arg or --content)")))
14982
15251
  .option("--content <text>", "memory content (alias for positional arg)")
14983
15252
  .option("--durability <d>", "permanent|persistent|standard|ephemeral (default standard). Also decides the default visibility when --visibility is omitted: permanent/persistent -> shared, standard/ephemeral -> private").option("--tags <csv>")
14984
15253
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
@@ -14992,10 +15261,15 @@ memory.command("add [content]")
14992
15261
  console.error("error: content required (positional arg or --content)");
14993
15262
  process.exit(1);
14994
15263
  }
14995
- const agentId = resolveSigningAgentId(opts, "memory add") ?? opts.agent;
14996
- const memId = `${opts.agent}-${Date.now()}`;
15264
+ applyAdminPassFile(opts);
15265
+ const agentId = resolveSigningAgentId(opts, "memory add");
15266
+ if (!agentId) {
15267
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15268
+ process.exit(2);
15269
+ }
15270
+ const memId = `${agentId}-${Date.now()}`;
14997
15271
  const body = {
14998
- id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
15272
+ id: memId, agentId, content, durability: opts.durability || "standard",
14999
15273
  tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
15000
15274
  type: "memory", createdAt: new Date().toISOString(),
15001
15275
  };
@@ -15028,7 +15302,11 @@ memory.command("add [content]")
15028
15302
  if (entities.length > 0)
15029
15303
  body.entities = entities;
15030
15304
  }
15031
- const out = await api("PUT", `/Memory/${memId}`, body, { agentId });
15305
+ const out = await api("PUT", `/Memory/${memId}`, body, {
15306
+ agentId,
15307
+ explicitAdminPass: opts.adminPass,
15308
+ adminUser: opts.adminUser,
15309
+ });
15032
15310
  console.log(JSON.stringify(out, null, 2));
15033
15311
  });
15034
15312
  // ─── flair memory write-task-summary ────────────────────────────────────────
@@ -16274,7 +16552,14 @@ bridge
16274
16552
  process.exit(2);
16275
16553
  }
16276
16554
  try {
16277
- const result = await runRoundTrip({ descriptor: loaded.descriptor, cwd, fixturePath: opts.fixture });
16555
+ const result = await runRoundTrip({
16556
+ descriptor: loaded.descriptor,
16557
+ cwd,
16558
+ fixturePath: opts.fixture,
16559
+ // Keep the intermediate export so a failure can print a live path.
16560
+ // The next harness start sweeps leftovers older than a minute (flair#1032).
16561
+ retainTmpDir: true,
16562
+ });
16278
16563
  if (opts.json) {
16279
16564
  console.log(JSON.stringify(result, null, 2));
16280
16565
  process.exit(result.passed ? 0 : 1);
@@ -16481,31 +16766,19 @@ function printTrustError(detail) {
16481
16766
  }
16482
16767
  }
16483
16768
  // ─── flair backup ────────────────────────────────────────────────────────────
16484
- program
16769
+ addSharedCredentialOptions(program
16485
16770
  .command("backup")
16486
16771
  .description("Export agents, memories, and souls to a JSON archive")
16487
16772
  .option("--output <path>", "Output file path (default: ~/.flair/backups/flair-backup-<timestamp>.json)")
16488
16773
  .option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
16489
16774
  .option("--port <port>", "Harper HTTP port")
16490
- .option("--url <url>", "Flair base URL (overrides --port)")
16491
- .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
16492
- .option("--admin-pass-file <path>", "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
16493
- .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16494
- .action(async (opts) => {
16775
+ .option("--url <url>", "Flair base URL (overrides --port)")).action(async (opts) => {
16495
16776
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
16496
- let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
16497
- if (!adminPass && opts.adminPassFile) {
16498
- // readAdminPassFileSecure refuses world/group readable files (mode 0600
16499
- // recommended). Common gotcha: files generated via
16500
- // `openssl rand -base64 24 > admin-pass` end in a newline; helper trims it.
16501
- try {
16502
- adminPass = readAdminPassFileSecure(opts.adminPassFile);
16503
- }
16504
- catch (err) {
16505
- console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
16506
- process.exit(1);
16507
- }
16508
- }
16777
+ applyAdminPassFile(opts);
16778
+ // Env is a second-class fallback after the explicit flags (same order
16779
+ // backup used before the shared helper). FLAIR_ADMIN_PASS is still
16780
+ // accepted so existing scripts keep working.
16781
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
16509
16782
  const adminUser = resolveAdminUser(opts.adminUser);
16510
16783
  if (!adminPass) {
16511
16784
  console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");