@tpsdev-ai/flair 0.52.0 → 0.54.1
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 +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1914 -15328
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1654 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +914 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1592 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/fabric-npm-install.js +87 -0
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +199 -0
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/stabilize-mqtt-network.js +123 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +43 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +15 -7
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
- package/package.json +9 -4
- package/schemas/agent.graphql +15 -0
- package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
import { applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook } from "../doctor-client.js";
|
|
2
|
+
import { hookSettingsPath } from "../hook-install.js";
|
|
3
|
+
import { detectClients, renderWiringSummary, wireAntigravity, wireCodex, wireCursor, wireGemini, wirePi } from "../install/clients.js";
|
|
4
|
+
import { DEFAULT_ADMIN_USER, authFetch, defaultKeysDir, readAdminPassFileSecure, resolveAdminUser } from "../lib/auth-resolve.js";
|
|
5
|
+
import { mcpServerSpec, unpinnedSpecWarning } from "../lib/mcp-spec.js";
|
|
6
|
+
import * as render from "../render.js";
|
|
7
|
+
import { execSync, spawn } from "node:child_process";
|
|
8
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import nacl from "tweetnacl";
|
|
13
|
+
let cli;
|
|
14
|
+
/** Bind the cli-locals this module depends on. */
|
|
15
|
+
export function bindCli(fns) {
|
|
16
|
+
cli = fns;
|
|
17
|
+
}
|
|
18
|
+
function api(...args) {
|
|
19
|
+
return cli.api(...args);
|
|
20
|
+
}
|
|
21
|
+
function b64url(...args) {
|
|
22
|
+
return cli.b64url(...args);
|
|
23
|
+
}
|
|
24
|
+
function buildLaunchdPlist(...args) {
|
|
25
|
+
return cli.buildLaunchdPlist(...args);
|
|
26
|
+
}
|
|
27
|
+
function buildOperationsApiConfig(...args) {
|
|
28
|
+
return cli.buildOperationsApiConfig(...args);
|
|
29
|
+
}
|
|
30
|
+
function cleanupLegacyLaunchdPlist(...args) {
|
|
31
|
+
return cli.cleanupLegacyLaunchdPlist(...args);
|
|
32
|
+
}
|
|
33
|
+
function defaultDataDir(...args) {
|
|
34
|
+
return cli.defaultDataDir(...args);
|
|
35
|
+
}
|
|
36
|
+
function defaultLaunchAgentsDir(...args) {
|
|
37
|
+
return cli.defaultLaunchAgentsDir(...args);
|
|
38
|
+
}
|
|
39
|
+
function ensureFlairAgentRole(...args) {
|
|
40
|
+
return cli.ensureFlairAgentRole(...args);
|
|
41
|
+
}
|
|
42
|
+
function ensureFlairAgentUser(...args) {
|
|
43
|
+
return cli.ensureFlairAgentUser(...args);
|
|
44
|
+
}
|
|
45
|
+
function ensureFlairPairInitiatorRole(...args) {
|
|
46
|
+
return cli.ensureFlairPairInitiatorRole(...args);
|
|
47
|
+
}
|
|
48
|
+
function flairPackageDir(...args) {
|
|
49
|
+
return cli.flairPackageDir(...args);
|
|
50
|
+
}
|
|
51
|
+
function harperBin(...args) {
|
|
52
|
+
return cli.harperBin(...args);
|
|
53
|
+
}
|
|
54
|
+
function harperConfigPath(...args) {
|
|
55
|
+
return cli.harperConfigPath(...args);
|
|
56
|
+
}
|
|
57
|
+
function launchdLabel(...args) {
|
|
58
|
+
return cli.launchdLabel(...args);
|
|
59
|
+
}
|
|
60
|
+
function launchdPlistPath(...args) {
|
|
61
|
+
return cli.launchdPlistPath(...args);
|
|
62
|
+
}
|
|
63
|
+
function opsNetworkPortValue(...args) {
|
|
64
|
+
return cli.opsNetworkPortValue(...args);
|
|
65
|
+
}
|
|
66
|
+
function persistDefaultInstallCoordinates(...args) {
|
|
67
|
+
return cli.persistDefaultInstallCoordinates(...args);
|
|
68
|
+
}
|
|
69
|
+
function privKeyPath(...args) {
|
|
70
|
+
return cli.privKeyPath(...args);
|
|
71
|
+
}
|
|
72
|
+
function provisionFabric(...args) {
|
|
73
|
+
return cli.provisionFabric(...args);
|
|
74
|
+
}
|
|
75
|
+
function pubKeyPath(...args) {
|
|
76
|
+
return cli.pubKeyPath(...args);
|
|
77
|
+
}
|
|
78
|
+
function readyOpsSocketPosture(...args) {
|
|
79
|
+
return cli.readyOpsSocketPosture(...args);
|
|
80
|
+
}
|
|
81
|
+
function resolveHttpPort(...args) {
|
|
82
|
+
return cli.resolveHttpPort(...args);
|
|
83
|
+
}
|
|
84
|
+
function resolveInitAdminPasswordSource(...args) {
|
|
85
|
+
return cli.resolveInitAdminPasswordSource(...args);
|
|
86
|
+
}
|
|
87
|
+
function resolveOpsBindHost(...args) {
|
|
88
|
+
return cli.resolveOpsBindHost(...args);
|
|
89
|
+
}
|
|
90
|
+
function resolveOpsPort(...args) {
|
|
91
|
+
return cli.resolveOpsPort(...args);
|
|
92
|
+
}
|
|
93
|
+
function resolveOpsTarget(...args) {
|
|
94
|
+
return cli.resolveOpsTarget(...args);
|
|
95
|
+
}
|
|
96
|
+
function resolveOpsUrlFromTarget(...args) {
|
|
97
|
+
return cli.resolveOpsUrlFromTarget(...args);
|
|
98
|
+
}
|
|
99
|
+
function resolveTarget(...args) {
|
|
100
|
+
return cli.resolveTarget(...args);
|
|
101
|
+
}
|
|
102
|
+
function runSoulWizard(...args) {
|
|
103
|
+
return cli.runSoulWizard(...args);
|
|
104
|
+
}
|
|
105
|
+
function seedAgentViaOpsApi(...args) {
|
|
106
|
+
return cli.seedAgentViaOpsApi(...args);
|
|
107
|
+
}
|
|
108
|
+
function seedFederationInstanceViaOpsApi(...args) {
|
|
109
|
+
return cli.seedFederationInstanceViaOpsApi(...args);
|
|
110
|
+
}
|
|
111
|
+
function shouldShowInlineSecretWarning(...args) {
|
|
112
|
+
return cli.shouldShowInlineSecretWarning(...args);
|
|
113
|
+
}
|
|
114
|
+
function verifyAuditLog(...args) {
|
|
115
|
+
return cli.verifyAuditLog(...args);
|
|
116
|
+
}
|
|
117
|
+
function verifySemanticSearch(...args) {
|
|
118
|
+
return cli.verifySemanticSearch(...args);
|
|
119
|
+
}
|
|
120
|
+
function waitForHealth(...args) {
|
|
121
|
+
return cli.waitForHealth(...args);
|
|
122
|
+
}
|
|
123
|
+
function writeDaemonSidecar(...args) {
|
|
124
|
+
return cli.writeDaemonSidecar(...args);
|
|
125
|
+
}
|
|
126
|
+
export function register(program) {
|
|
127
|
+
const MQTT_DISABLED_CONFIG = cli.MQTT_DISABLED_CONFIG;
|
|
128
|
+
const STARTUP_TIMEOUT_MS = cli.STARTUP_TIMEOUT_MS;
|
|
129
|
+
// ─── flair init ──────────────────────────────────────────────────────────────
|
|
130
|
+
program
|
|
131
|
+
.command("init")
|
|
132
|
+
.description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
|
|
133
|
+
.option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
|
|
134
|
+
.option("--agent <id>", "Alias for --agent-id")
|
|
135
|
+
// No commander default (flair#928). A default here is indistinguishable from
|
|
136
|
+
// the user typing it, so a BARE `flair init` used to state DEFAULT_PORT and
|
|
137
|
+
// renumber an instance already serving a custom one. Absent means absent, and
|
|
138
|
+
// resolveHttpPort's "create" ladder supplies DEFAULT_PORT for a genuinely new
|
|
139
|
+
// instance — which is the only case that ever wanted one.
|
|
140
|
+
.option("--port <port>", "Harper HTTP port (default: this instance's current port, or 19926 for a new one)")
|
|
141
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
142
|
+
.option("--ops-bind <addr>", "Harper ops API bind address (env: FLAIR_OPS_BIND; default: 127.0.0.1 loopback-only for single-host — pass e.g. 0.0.0.0 for multi-host/Fabric remote admin)")
|
|
143
|
+
.option("--admin-pass <pass>", "Admin password (generated if omitted)")
|
|
144
|
+
.option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
|
|
145
|
+
.option("--admin-user <name>", "Admin username when authenticating to an already-running instance via --target/--ops-target (env: FLAIR_ADMIN_USER; default: admin — local bootstrap and Fabric provisioning always create 'admin')")
|
|
146
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
147
|
+
.option("--data-dir <dir>", "Harper data directory")
|
|
148
|
+
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
149
|
+
.option("--skip-soul", "Skip interactive personality setup")
|
|
150
|
+
.option("--client <client>", "Client(s) to wire: claude-code, codex, gemini, cursor, antigravity, pi (native extension), all, or none")
|
|
151
|
+
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
152
|
+
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
153
|
+
.option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
|
|
154
|
+
.option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code and Codex)")
|
|
155
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
156
|
+
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
157
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
158
|
+
.option("--force", "Skip confirmation prompt for remote writes (required with --target)")
|
|
159
|
+
.option("--cluster-admin-user <user>", "Harper cluster admin username (env: FLAIR_CLUSTER_ADMIN_USER)")
|
|
160
|
+
.option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
|
|
161
|
+
.option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
|
|
162
|
+
.action(async (opts) => {
|
|
163
|
+
const agentId = opts.agentId ?? opts.agent;
|
|
164
|
+
const target = resolveTarget(opts);
|
|
165
|
+
const opsTarget = resolveOpsTarget(opts);
|
|
166
|
+
// ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
|
|
167
|
+
if (target || opsTarget) {
|
|
168
|
+
// When -only- --ops-target is provided, attempt to derive REST URL
|
|
169
|
+
if (!target && opsTarget) {
|
|
170
|
+
console.error("Error: --ops-target requires --target as well. Pass --target <rest-url> for the REST API surface.");
|
|
171
|
+
console.error(" Currently only explicit --ops-target + --target combination is supported.");
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const baseUrl = target.replace(/\/$/, "");
|
|
175
|
+
// --ops-target overrides derivation; otherwise derive from --target
|
|
176
|
+
const opsUrl = opsTarget ? opsTarget.replace(/\/$/, "") : resolveOpsUrlFromTarget(baseUrl);
|
|
177
|
+
// Check for cluster-admin provisioning (new atomic flow)
|
|
178
|
+
const clusterAdminUser = opts.clusterAdminUser || process.env.FLAIR_CLUSTER_ADMIN_USER;
|
|
179
|
+
const clusterAdminPass = opts.clusterAdminPass || process.env.FLAIR_CLUSTER_ADMIN_PASS;
|
|
180
|
+
let flairAdminPass = opts.flairAdminPass || process.env.FLAIR_ADMIN_PASS;
|
|
181
|
+
let didProvision = false;
|
|
182
|
+
if (clusterAdminUser && clusterAdminPass) {
|
|
183
|
+
// ── New provisioning path: deploy Flair to Fabric, wait, provision super_user ──
|
|
184
|
+
if (!opts.force) {
|
|
185
|
+
console.error("Error: --force is required with --target/--ops-target (remote init provisions a live Fabric instance)");
|
|
186
|
+
console.error(" Pass --force to confirm this is intended.");
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
// Generate flair admin pass if not provided
|
|
190
|
+
if (!flairAdminPass) {
|
|
191
|
+
flairAdminPass = randomBytes(24).toString("base64url");
|
|
192
|
+
}
|
|
193
|
+
// Write the flair admin pass to secrets directory
|
|
194
|
+
const secretsDir = join(homedir(), ".tps", "secrets");
|
|
195
|
+
mkdirSync(secretsDir, { recursive: true });
|
|
196
|
+
const secretPath = join(secretsDir, "flair-fabric-hdb");
|
|
197
|
+
writeFileSync(secretPath, flairAdminPass + "\n", { mode: 0o600 });
|
|
198
|
+
console.log(`Admin password written to ${secretPath}`);
|
|
199
|
+
// Atomic provisioning: deploy + wait + provision user
|
|
200
|
+
await provisionFabric(baseUrl, opsUrl, clusterAdminUser, clusterAdminPass, flairAdminPass);
|
|
201
|
+
didProvision = true;
|
|
202
|
+
// Hub instances (--remote) receive federation pair requests and need
|
|
203
|
+
// the flair_pair_initiator role so bootstrap credentials can pass
|
|
204
|
+
// platform auth before reaching the FederationPair resource handler.
|
|
205
|
+
if (opts.remote) {
|
|
206
|
+
await ensureFlairPairInitiatorRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
207
|
+
}
|
|
208
|
+
// Every flair instance has agents, so provision the least-privilege
|
|
209
|
+
// flair_agent role (idempotent, harmless until a user is assigned to it).
|
|
210
|
+
await ensureFlairAgentRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
211
|
+
// THE FLIP (auth-rbac): provision the shared least-privilege flair-agent user.
|
|
212
|
+
// This ACTIVATES the gate's per-agent de-elevation (verified non-admin agents
|
|
213
|
+
// resolve to flair-agent instead of admin super_user). Safe now: #487 gave
|
|
214
|
+
// every agent-facing resource its own allow* + resolveAgentAuth, so they no
|
|
215
|
+
// longer rely on the admin super_user bypass. The gate also falls back to
|
|
216
|
+
// admin if this user is ever absent, so de-elevation degrades gracefully.
|
|
217
|
+
await ensureFlairAgentUser(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// ── Existing behavior: --admin-pass required for already-running Flair ──
|
|
221
|
+
if (!opts.adminPass) {
|
|
222
|
+
console.error("Error: --admin-pass is required with --target/--ops-target (remote init without --cluster-admin-user/--cluster-admin-pass)");
|
|
223
|
+
console.error(" Use --cluster-admin-user and --cluster-admin-pass for automated Fabric provisioning.");
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
if (!opts.force) {
|
|
227
|
+
const displayTarget = target || opsTarget;
|
|
228
|
+
console.error(`Error: --force is required with --target/--ops-target. Remote init writes to a live Flair instance at ${displayTarget}.`);
|
|
229
|
+
console.error(" Pass --force to confirm this is intended.");
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
flairAdminPass = opts.adminPass;
|
|
233
|
+
}
|
|
234
|
+
// flair#1345: only the already-running-instance leg honors --admin-user /
|
|
235
|
+
// FLAIR_ADMIN_USER — the provisioning leg just CREATED the superuser as
|
|
236
|
+
// DEFAULT_ADMIN_USER via provisionFabric, so that name is ground truth.
|
|
237
|
+
const adminUser = didProvision ? DEFAULT_ADMIN_USER : resolveAdminUser(opts.adminUser);
|
|
238
|
+
const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
|
|
239
|
+
const role = opts.remote ? "hub" : undefined;
|
|
240
|
+
// Generate or reuse keypair (only if --agent-id provided, or --remote needs
|
|
241
|
+
// a public key for the FederationInstance row)
|
|
242
|
+
let pubKeyB64url;
|
|
243
|
+
let privPath;
|
|
244
|
+
let instanceId;
|
|
245
|
+
if (agentId || role) {
|
|
246
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
247
|
+
mkdirSync(keysDir, { recursive: true });
|
|
248
|
+
if (agentId) {
|
|
249
|
+
privPath = privKeyPath(agentId, keysDir);
|
|
250
|
+
const pubPath = pubKeyPath(agentId, keysDir);
|
|
251
|
+
if (existsSync(privPath)) {
|
|
252
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
253
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
254
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
255
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
console.log("Generating Ed25519 keypair...");
|
|
259
|
+
const kp = nacl.sign.keyPair();
|
|
260
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
261
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
262
|
+
chmodSync(privPath, 0o600);
|
|
263
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
264
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
265
|
+
console.log(`Keypair written: ${privPath} ✓`);
|
|
266
|
+
}
|
|
267
|
+
// Seed agent via remote ops API
|
|
268
|
+
console.log(`Seeding agent '${agentId}' on ${baseUrl}...`);
|
|
269
|
+
await seedAgentViaOpsApi(opsUrl, agentId, pubKeyB64url, adminUser, flairAdminPass);
|
|
270
|
+
console.log(`Agent '${agentId}' registered on remote instance ✓`);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
// No agentId -- generate throwaway keypair for FederationInstance row
|
|
274
|
+
console.log("Generating federation instance keypair...");
|
|
275
|
+
const kp = nacl.sign.keyPair();
|
|
276
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
console.log("No --agent-id provided -- skipping agent registration");
|
|
281
|
+
}
|
|
282
|
+
// Write FederationInstance row if --remote (hub role)
|
|
283
|
+
if (role) {
|
|
284
|
+
if (!pubKeyB64url) {
|
|
285
|
+
const kp = nacl.sign.keyPair();
|
|
286
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
287
|
+
}
|
|
288
|
+
instanceId = randomUUID();
|
|
289
|
+
console.log(`Writing federation Instance (role=${role}) via ops API...`);
|
|
290
|
+
await seedFederationInstanceViaOpsApi(opsUrl, instanceId, pubKeyB64url, role, adminUser, flairAdminPass);
|
|
291
|
+
console.log(`Federation Instance created: ${instanceId} (${role}) ✓`);
|
|
292
|
+
}
|
|
293
|
+
// Verify connectivity
|
|
294
|
+
if (didProvision) {
|
|
295
|
+
// Use /FederationInstance with Basic auth (not /Health which false-401s on Fabric)
|
|
296
|
+
console.log("Verifying remote connectivity...");
|
|
297
|
+
const verifyRes = await fetch(`${baseUrl}/FederationInstance`, {
|
|
298
|
+
headers: { Authorization: auth },
|
|
299
|
+
signal: AbortSignal.timeout(5000),
|
|
300
|
+
});
|
|
301
|
+
if (!verifyRes.ok) {
|
|
302
|
+
const body = await verifyRes.text().catch(() => "");
|
|
303
|
+
console.error(`Remote verification failed (${verifyRes.status}): ${body}`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
console.log("✓ Hub ready at " + baseUrl);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
// Existing behavior: /Health check (already-running Flair)
|
|
310
|
+
console.log("Verifying remote connectivity...");
|
|
311
|
+
const verifyRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
|
|
312
|
+
if (!verifyRes.ok) {
|
|
313
|
+
console.error(`Remote health check failed: ${verifyRes.status}`);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
console.log("Remote Flair instance healthy ✓");
|
|
317
|
+
}
|
|
318
|
+
// Print summary
|
|
319
|
+
if (didProvision) {
|
|
320
|
+
const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8"));
|
|
321
|
+
const flavor = pkg.version || "(unknown)";
|
|
322
|
+
const displayTarget = target || opsTarget;
|
|
323
|
+
console.log(`\n✓ Flair hub deployed to ${displayTarget}`);
|
|
324
|
+
console.log(` Component: flair@${flavor}`);
|
|
325
|
+
console.log(` Admin user: ${adminUser} (pass written to ${join(homedir(), ".tps", "secrets", "flair-fabric-hdb")})`);
|
|
326
|
+
if (instanceId)
|
|
327
|
+
console.log(` Instance: ${instanceId} (role=${role})`);
|
|
328
|
+
console.log(` Federation: ready — run \`flair federation token\` to mint a pairing token`);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
console.log(`\n✅ Remote Flair initialized`);
|
|
332
|
+
if (agentId)
|
|
333
|
+
console.log(` Agent ID: ${agentId}`);
|
|
334
|
+
console.log(` Target: ${baseUrl}`);
|
|
335
|
+
if (agentId)
|
|
336
|
+
console.log(` Private key: ${privPath}`);
|
|
337
|
+
if (role)
|
|
338
|
+
console.log(` Role: ${role}`);
|
|
339
|
+
console.log(`\n Export: FLAIR_URL=${baseUrl}`);
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// ── Local init (full one-command setup) ──
|
|
344
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
345
|
+
const dataDir = opts.dataDir ?? defaultDataDir();
|
|
346
|
+
// "create" mode (flair#914): init ESTABLISHES an instance, so a data
|
|
347
|
+
// directory with no recorded port is a new instance taking the default,
|
|
348
|
+
// not the hard error every other caller gets — otherwise `flair init
|
|
349
|
+
// --data-dir <new>` could never succeed. `dataDir` is resolved first so
|
|
350
|
+
// this is never asked before the instance is known.
|
|
351
|
+
//
|
|
352
|
+
// flair#928: `--port` deliberately carries NO commander default, so a bare
|
|
353
|
+
// `init` reaches the ladder below instead of restating DEFAULT_PORT and
|
|
354
|
+
// renumbering an instance that already serves a custom port. `init` is
|
|
355
|
+
// `flair doctor`'s standing remedy and is recommended in ten places, so the
|
|
356
|
+
// command handed to an operator whose install is already wrong must not be
|
|
357
|
+
// the one that moves their port.
|
|
358
|
+
const httpPort = resolveHttpPort(opts, "create");
|
|
359
|
+
// The already-resolved port is handed to the ops resolver rather than
|
|
360
|
+
// letting it re-resolve — its last rung is `resolveHttpPort(opts) - 1`,
|
|
361
|
+
// which would ask the same question again in "address" mode.
|
|
362
|
+
const opsPort = resolveOpsPort({ ...opts, port: httpPort });
|
|
363
|
+
const opsBindHost = resolveOpsBindHost(opts);
|
|
364
|
+
// Resolve MCP client selection (union of init's auto-wire + the multi-client
|
|
365
|
+
// detection/wiring that the front-door command provides). `--no-mcp` sets
|
|
366
|
+
// opts.mcp === false (commander negates the flag). Validate an explicit
|
|
367
|
+
// --client up front so a typo fails before Harper is touched.
|
|
368
|
+
const clientOpt = opts.client;
|
|
369
|
+
const noMcp = opts.mcp === false;
|
|
370
|
+
const selectedClients = [];
|
|
371
|
+
if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
|
|
372
|
+
const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity", "pi"];
|
|
373
|
+
if (!valid.includes(clientOpt)) {
|
|
374
|
+
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, pi, all, none`);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
selectedClients.push(clientOpt);
|
|
378
|
+
}
|
|
379
|
+
// Admin password: determine from opts, env, or generate
|
|
380
|
+
// Priority: 1) --admin-pass-file, 2) env vars, 3) reuse existing file, 4) generate new
|
|
381
|
+
let adminPass;
|
|
382
|
+
let passwordSource = "generated";
|
|
383
|
+
let reusedExistingAdminPass = false;
|
|
384
|
+
// Warn if --admin-pass is passed inline (not from env)
|
|
385
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, false, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
386
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-file <path> or FLAIR_ADMIN_PASS env " +
|
|
387
|
+
"to keep secrets out of shell history.");
|
|
388
|
+
}
|
|
389
|
+
// Read from file if provided
|
|
390
|
+
if (opts.adminPassFile) {
|
|
391
|
+
try {
|
|
392
|
+
adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
console.error(`Error: ${err.message}`);
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
passwordSource = "file";
|
|
399
|
+
}
|
|
400
|
+
else if (process.env.FLAIR_ADMIN_PASS) {
|
|
401
|
+
adminPass = process.env.FLAIR_ADMIN_PASS;
|
|
402
|
+
passwordSource = "env";
|
|
403
|
+
}
|
|
404
|
+
else if (process.env.HDB_ADMIN_PASSWORD) {
|
|
405
|
+
adminPass = process.env.HDB_ADMIN_PASSWORD;
|
|
406
|
+
passwordSource = "env";
|
|
407
|
+
}
|
|
408
|
+
else if (opts.adminPass) {
|
|
409
|
+
// Inline admin pass (deprecated)
|
|
410
|
+
adminPass = opts.adminPass;
|
|
411
|
+
// Don't generate - don't write to file
|
|
412
|
+
passwordSource = "env"; // Treat same as env for display purposes
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
const flairDir = join(homedir(), ".flair");
|
|
416
|
+
const adminPassPath = join(flairDir, "admin-pass");
|
|
417
|
+
passwordSource = "generated";
|
|
418
|
+
if (resolveInitAdminPasswordSource(existsSync(adminPassPath)) === "reuse-existing") {
|
|
419
|
+
// flair#827: an admin-pass file already on disk means a PRIOR `flair
|
|
420
|
+
// init` already bootstrapped Harper's admin user with this password.
|
|
421
|
+
// HDB_ADMIN_PASSWORD only seeds a brand-new install — Harper does
|
|
422
|
+
// NOT rotate an existing user's stored password hash from env on
|
|
423
|
+
// every boot. Generating and overwriting the file here would desync
|
|
424
|
+
// it from what Harper actually has persisted, breaking ops-API auth
|
|
425
|
+
// (401 "Login failed") on THIS SAME init run (the agent-seeding call
|
|
426
|
+
// below) without fixing whatever the re-run was meant to fix — e.g.
|
|
427
|
+
// `flair doctor`'s ops-bind finding, whose only prescribed remedy is
|
|
428
|
+
// re-running `flair init`. Re-init must be idempotent here: reuse
|
|
429
|
+
// the existing password so it's always safe to re-run against a
|
|
430
|
+
// working install. Rotating the admin password on purpose is a
|
|
431
|
+
// separate, deliberate operation (see the ops runbook), not a side
|
|
432
|
+
// effect of re-init.
|
|
433
|
+
try {
|
|
434
|
+
adminPass = readAdminPassFileSecure(adminPassPath);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
console.error(`Error: ${err.message}`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
reusedExistingAdminPass = true;
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
// Generate new password and write to file atomically
|
|
444
|
+
adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
|
|
445
|
+
// Atomic write: create temp file in same dir, then rename
|
|
446
|
+
mkdirSync(flairDir, { recursive: true });
|
|
447
|
+
const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
|
|
448
|
+
const finalTempPath = join(tempPath, "admin-pass");
|
|
449
|
+
try {
|
|
450
|
+
writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
|
|
451
|
+
renameSync(finalTempPath, adminPassPath);
|
|
452
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
// Clean up temp dir on failure
|
|
456
|
+
try {
|
|
457
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
catch { }
|
|
460
|
+
throw err;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const adminUser = DEFAULT_ADMIN_USER;
|
|
465
|
+
// If we generated (or reused) the password, report where it lives
|
|
466
|
+
if (passwordSource === "generated") {
|
|
467
|
+
const adminPassPath = join(homedir(), ".flair", "admin-pass");
|
|
468
|
+
if (reusedExistingAdminPass) {
|
|
469
|
+
console.log(`Reusing existing admin password from: ${adminPassPath} (flair#827: re-init never rotates it — see the ops runbook to change it deliberately)`);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
console.log(`Admin password saved to: ${adminPassPath}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Check Node.js version
|
|
476
|
+
const major = parseInt(process.version.slice(1), 10);
|
|
477
|
+
if (major < 18)
|
|
478
|
+
throw new Error(`Node.js >= 18 required (found ${process.version})`);
|
|
479
|
+
let alreadyRunning = false;
|
|
480
|
+
// <ROOTPATH>/models — resources/embeddings-provider.ts's resolveModelsDir()
|
|
481
|
+
// tier 2 default; an operator override already in the environment wins
|
|
482
|
+
// (tier 1). Scoped above the alreadyRunning branch below (not just inside
|
|
483
|
+
// the fresh-start path) since the launchd plist step needs it too, even
|
|
484
|
+
// when Harper was already running and the fresh-spawn branch was skipped.
|
|
485
|
+
const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
|
|
486
|
+
// flair#763: put the ops-socket directory gate in place BEFORE Harper
|
|
487
|
+
// spawns, so the socket is never reachable during the create→chmod window
|
|
488
|
+
// (the dir gate is the race-free primary control). This also validates
|
|
489
|
+
// FLAIR_SOCKET_GROUP early — a bad group fails fast, before a full boot.
|
|
490
|
+
// The socket doesn't exist yet, so only the parent-dir mode is applied here.
|
|
491
|
+
mkdirSync(dataDir, { recursive: true });
|
|
492
|
+
readyOpsSocketPosture(dataDir);
|
|
493
|
+
if (!opts.skipStart) {
|
|
494
|
+
// Check if already running
|
|
495
|
+
try {
|
|
496
|
+
const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
|
|
497
|
+
if (res.status > 0) {
|
|
498
|
+
alreadyRunning = true;
|
|
499
|
+
console.log(`Harper already running on port ${httpPort} — skipping start`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch { /* not running */ }
|
|
503
|
+
if (!alreadyRunning) {
|
|
504
|
+
const bin = harperBin();
|
|
505
|
+
if (!bin) {
|
|
506
|
+
throw new Error("Harper CLI not found: no dist/bin/harper.js under node_modules/harper " +
|
|
507
|
+
"(or the legacy node_modules/@harperfast/harper) next to this flair " +
|
|
508
|
+
`install or in ${process.cwd()}.\n` +
|
|
509
|
+
"Flair ships Harper as a dependency, so this normally means a partial " +
|
|
510
|
+
"or interrupted install.\nFix: reinstall flair — npm install -g @tpsdev-ai/flair");
|
|
511
|
+
}
|
|
512
|
+
mkdirSync(dataDir, { recursive: true });
|
|
513
|
+
// Detect whether Harper has already been installed in this data dir.
|
|
514
|
+
// Harper's config is created during install — its presence means
|
|
515
|
+
// install already ran. Re-running install against an existing data dir
|
|
516
|
+
// crashes in Harper v5 beta.6+ (checkForExistingInstall queries the
|
|
517
|
+
// database before the env is initialized). Goes through
|
|
518
|
+
// harperConfigPath so an install predating Harper's config-file rename
|
|
519
|
+
// (harperdb-config.yaml) is still recognised as installed rather than
|
|
520
|
+
// re-installed over.
|
|
521
|
+
const alreadyInstalled = harperConfigPath(dataDir) !== null;
|
|
522
|
+
const opsSocket = join(dataDir, "operations-server");
|
|
523
|
+
// authorizeLocal: false (flair#654) — a credential-less loopback ops-API
|
|
524
|
+
// request is no longer auto-authorized as super_user. Every ops-API
|
|
525
|
+
// seed call below (seedAgentViaOpsApi et al.) already passes a real
|
|
526
|
+
// adminPass via Basic auth, so this does not change local-init behavior.
|
|
527
|
+
// operationsApi (flair#670): loopback-only by default (buildOperationsApiConfig
|
|
528
|
+
// — see its doc comment for the "host:port" bind mechanism and the
|
|
529
|
+
// domainSocket schema path), escape hatch via --ops-bind/FLAIR_OPS_BIND.
|
|
530
|
+
const harperSetConfig = JSON.stringify({
|
|
531
|
+
rootPath: dataDir,
|
|
532
|
+
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
533
|
+
operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
|
|
534
|
+
mqtt: MQTT_DISABLED_CONFIG,
|
|
535
|
+
localStudio: { enabled: false },
|
|
536
|
+
authentication: { authorizeLocal: false, enableSessions: true },
|
|
537
|
+
});
|
|
538
|
+
const env = {
|
|
539
|
+
...process.env,
|
|
540
|
+
ROOTPATH: dataDir,
|
|
541
|
+
FLAIR_MODELS_DIR: modelsDir,
|
|
542
|
+
HARPER_SET_CONFIG: harperSetConfig,
|
|
543
|
+
DEFAULTS_MODE: "dev",
|
|
544
|
+
HDB_ADMIN_USERNAME: adminUser,
|
|
545
|
+
HDB_ADMIN_PASSWORD: adminPass,
|
|
546
|
+
THREADS_COUNT: "1",
|
|
547
|
+
NODE_HOSTNAME: "localhost",
|
|
548
|
+
HTTP_PORT: String(httpPort),
|
|
549
|
+
// flair#863: host-qualified, NOT a bare port. A bare value here is
|
|
550
|
+
// what Harper latches as `originalValues["operationsApi.network.port"]`
|
|
551
|
+
// when HARPER_SET_CONFIG force-sets the same key — and restores on the
|
|
552
|
+
// first later boot without HARPER_SET_CONFIG, re-widening the bind to
|
|
553
|
+
// all interfaces. See opsNetworkPortValue's doc comment.
|
|
554
|
+
OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opsBindHost, opsPort),
|
|
555
|
+
LOCAL_STUDIO: "false",
|
|
556
|
+
// flair#1586: same MQTT_* re-assert as buildDirectSpawnEnv / the
|
|
557
|
+
// launchd plist, so init cannot restore Harper's 1883/8883 defaults
|
|
558
|
+
// on a later boot that omits HARPER_SET_CONFIG.
|
|
559
|
+
MQTT_NETWORK_PORT: "null",
|
|
560
|
+
MQTT_NETWORK_SECUREPORT: "null",
|
|
561
|
+
MQTT_WEBSOCKET: "false",
|
|
562
|
+
};
|
|
563
|
+
// models (flair#504 Phase 1): the embedding backend registers itself
|
|
564
|
+
// in-process at boot (resources/embeddings-boot.ts, loaded by
|
|
565
|
+
// config.yaml's `jsResource` glob) — NOT via a config env var. See
|
|
566
|
+
// that file's header for why (flair#694: HARPER_CONFIG persisted a
|
|
567
|
+
// `models.embedding.default` block into harper-config.yaml that an
|
|
568
|
+
// older/downgraded build's boot would tear down to an invalid empty
|
|
569
|
+
// shell). FLAIR_MODELS_DIR above is still the channel that tells the
|
|
570
|
+
// registration where to find/download the model.
|
|
571
|
+
if (alreadyInstalled) {
|
|
572
|
+
console.log("Existing Harper installation found — skipping install.");
|
|
573
|
+
console.log("If something is wrong, run: flair doctor");
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// Isolate install from any global Harper boot file.
|
|
577
|
+
// ~/.harperdb/hdb_boot_properties.file from an unrelated install
|
|
578
|
+
// causes checkForExistingInstall to crash in Harper v5 beta.6+.
|
|
579
|
+
// Only applied to install — run needs real HOME for npm/node resolution.
|
|
580
|
+
const installEnv = { ...env, HOME: join(dataDir, "..") };
|
|
581
|
+
console.log("Installing Harper...");
|
|
582
|
+
console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
|
|
583
|
+
await new Promise((resolve, reject) => {
|
|
584
|
+
let output = "";
|
|
585
|
+
let dotTimer = null;
|
|
586
|
+
const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
|
|
587
|
+
// Print progress dots so the terminal doesn't appear frozen during model download
|
|
588
|
+
dotTimer = setInterval(() => process.stdout.write("."), 3000);
|
|
589
|
+
install.stdout?.on("data", (d) => { output += d.toString(); });
|
|
590
|
+
install.stderr?.on("data", (d) => { output += d.toString(); });
|
|
591
|
+
install.on("exit", (code) => {
|
|
592
|
+
if (dotTimer) {
|
|
593
|
+
clearInterval(dotTimer);
|
|
594
|
+
process.stdout.write("\n");
|
|
595
|
+
}
|
|
596
|
+
code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
|
|
597
|
+
});
|
|
598
|
+
install.on("error", (err) => {
|
|
599
|
+
if (dotTimer) {
|
|
600
|
+
clearInterval(dotTimer);
|
|
601
|
+
process.stdout.write("\n");
|
|
602
|
+
}
|
|
603
|
+
reject(err);
|
|
604
|
+
});
|
|
605
|
+
setTimeout(() => {
|
|
606
|
+
install.kill();
|
|
607
|
+
if (dotTimer) {
|
|
608
|
+
clearInterval(dotTimer);
|
|
609
|
+
process.stdout.write("\n");
|
|
610
|
+
}
|
|
611
|
+
reject(new Error(`Harper install timed out: ${output}`));
|
|
612
|
+
}, 60_000);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
// Start Harper with flair loaded as a component (the "." arg).
|
|
616
|
+
// ROOTPATH in env points to the data dir; authorizeLocal and thread
|
|
617
|
+
// count are set via HARPER_SET_CONFIG — no need for dev mode.
|
|
618
|
+
console.log(`Starting Harper on port ${httpPort}...`);
|
|
619
|
+
const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
|
|
620
|
+
proc.unref();
|
|
621
|
+
// flair#1454: write the identity sidecar immediately after spawn so
|
|
622
|
+
// `flair stop` and `flair status` can classify this daemon's state
|
|
623
|
+
// without lsof. Same call as startFlairProcess() uses.
|
|
624
|
+
if (proc.pid)
|
|
625
|
+
writeDaemonSidecar(dataDir, proc.pid, httpPort);
|
|
626
|
+
}
|
|
627
|
+
console.log("Waiting for Harper health check...");
|
|
628
|
+
await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
|
|
629
|
+
console.log("Harper is healthy ✓");
|
|
630
|
+
// flair#763: the socket now exists — apply its file mode (+ chgrp for the
|
|
631
|
+
// FLAIR_SOCKET_GROUP opt-in). The dir gate above is re-asserted idempotently.
|
|
632
|
+
readyOpsSocketPosture(dataDir);
|
|
633
|
+
// Register launchd service on macOS so Harper survives reboots
|
|
634
|
+
// and `flair restart` / `flair stop` work via launchctl.
|
|
635
|
+
if (process.platform === "darwin") {
|
|
636
|
+
const harperBinPath = harperBin();
|
|
637
|
+
if (harperBinPath) {
|
|
638
|
+
const label = launchdLabel(dataDir);
|
|
639
|
+
const plistDir = defaultLaunchAgentsDir();
|
|
640
|
+
mkdirSync(plistDir, { recursive: true });
|
|
641
|
+
const plistPath = launchdPlistPath(label, plistDir);
|
|
642
|
+
// flair#693 + flair#966: a pre-flair#693 install registered under
|
|
643
|
+
// the bare LEGACY_LAUNCHD_LABEL. init always writes fresh plist
|
|
644
|
+
// content below (it has the current ports/creds in hand), so
|
|
645
|
+
// migration here is just "clean up the old registration" —
|
|
646
|
+
// unload + remove it BEFORE writing the new one, so re-running
|
|
647
|
+
// init never leaves two services behind for this data dir.
|
|
648
|
+
//
|
|
649
|
+
// flair#966: the legacy plist is NOT scoped to this data dir —
|
|
650
|
+
// it is a single global label. cleanupLegacyLaunchdPlist reads
|
|
651
|
+
// ROOTPATH to establish ownership before touching it.
|
|
652
|
+
cleanupLegacyLaunchdPlist(dataDir, plistDir, (cmd) => {
|
|
653
|
+
execSync(cmd, { stdio: "pipe" });
|
|
654
|
+
});
|
|
655
|
+
const opsSocket = join(dataDir, "operations-server");
|
|
656
|
+
// authorizeLocal: false (flair#654) — same posture as the initial spawn
|
|
657
|
+
// above; the launchd-managed process must not diverge from it.
|
|
658
|
+
// operationsApi (flair#670): same buildOperationsApiConfig posture as the
|
|
659
|
+
// initial spawn above — the launchd-managed process must not diverge.
|
|
660
|
+
const setConfig = JSON.stringify({
|
|
661
|
+
rootPath: dataDir,
|
|
662
|
+
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
663
|
+
operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
|
|
664
|
+
mqtt: MQTT_DISABLED_CONFIG,
|
|
665
|
+
localStudio: { enabled: false },
|
|
666
|
+
authentication: { authorizeLocal: false, enableSessions: true },
|
|
667
|
+
});
|
|
668
|
+
// models (flair#504 Phase 1): no env var needed here — the
|
|
669
|
+
// launchd-managed process loads the SAME dist/resources/*.js as any
|
|
670
|
+
// other spawn, so resources/embeddings-boot.ts self-registers the
|
|
671
|
+
// backend on every KeepAlive restart in-process. See that file's
|
|
672
|
+
// header (flair#694) for why this replaced the old HARPER_CONFIG
|
|
673
|
+
// plist line.
|
|
674
|
+
const plist = buildLaunchdPlist({
|
|
675
|
+
label,
|
|
676
|
+
execPath: process.execPath,
|
|
677
|
+
harperBinPath,
|
|
678
|
+
workingDirectory: flairPackageDir(),
|
|
679
|
+
dataDir,
|
|
680
|
+
modelsDir,
|
|
681
|
+
setConfig,
|
|
682
|
+
adminUser,
|
|
683
|
+
adminPass,
|
|
684
|
+
httpPort,
|
|
685
|
+
opsNetworkPort: opsNetworkPortValue(opsBindHost, opsPort),
|
|
686
|
+
});
|
|
687
|
+
writeFileSync(plistPath, plist);
|
|
688
|
+
console.log("Launchd service registered ✓");
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// Persist the instance coordinates so other commands can find AND
|
|
693
|
+
// re-assert this instance. flair#863: `opsBind` in particular has to be
|
|
694
|
+
// persisted here, not just handed to this run's spawn — `flair start` /
|
|
695
|
+
// `flair restart` / `flair upgrade` re-assert the bind on every spawn and
|
|
696
|
+
// have no `--ops-bind` flag of their own, so this is the only thing that
|
|
697
|
+
// survives an `--ops-bind` choice past the next restart. It also runs on
|
|
698
|
+
// the already-running path (where init skips the spawn entirely), which is
|
|
699
|
+
// what makes doctor's `flair init && flair restart` remedy actually apply.
|
|
700
|
+
// flair#914: written ONLY when this init is about the default install, so a
|
|
701
|
+
// second instance can no longer overwrite the first's recorded port. This
|
|
702
|
+
// instance's own port needs no write here — Harper has just recorded it in
|
|
703
|
+
// <dataDir>/harper-config.yaml, which is what resolveHttpPort reads (see
|
|
704
|
+
// persistDefaultInstallCoordinates).
|
|
705
|
+
persistDefaultInstallCoordinates(dataDir, httpPort, opsPort, opsBindHost);
|
|
706
|
+
if (agentId) {
|
|
707
|
+
// Generate or reuse keypair
|
|
708
|
+
mkdirSync(keysDir, { recursive: true });
|
|
709
|
+
const privPath = privKeyPath(agentId, keysDir);
|
|
710
|
+
const pubPath = pubKeyPath(agentId, keysDir);
|
|
711
|
+
let pubKeyB64url;
|
|
712
|
+
if (existsSync(privPath)) {
|
|
713
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
714
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
715
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
716
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
console.log("Generating Ed25519 keypair...");
|
|
720
|
+
const kp = nacl.sign.keyPair();
|
|
721
|
+
// Store only the 32-byte seed (first 32 bytes of secretKey)
|
|
722
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
723
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
724
|
+
chmodSync(privPath, 0o600);
|
|
725
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
726
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
727
|
+
console.log(`Keypair written: ${privPath} ✓`);
|
|
728
|
+
}
|
|
729
|
+
// Seed agent via operations API
|
|
730
|
+
console.log(`Seeding agent '${agentId}' via operations API...`);
|
|
731
|
+
await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass);
|
|
732
|
+
console.log(`Agent '${agentId}' registered ✓`);
|
|
733
|
+
// Verify Ed25519 auth
|
|
734
|
+
console.log("Verifying Ed25519 auth...");
|
|
735
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
736
|
+
const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
|
|
737
|
+
if (!verifyRes.ok)
|
|
738
|
+
throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
|
|
739
|
+
console.log("Ed25519 auth verified ✓");
|
|
740
|
+
// Verify semantic search ACTUALLY works (real embed→paraphrase-search
|
|
741
|
+
// round-trip). A clean-VM dogfood found semantic search dead out of the box
|
|
742
|
+
// (sudo/root-owned install can't write the embeddings models symlink →
|
|
743
|
+
// EACCES) while init reported success. Never report a clean init when
|
|
744
|
+
// recall-by-meaning is broken. Skipped paths (no key yet) are non-fatal.
|
|
745
|
+
console.log("Verifying semantic search...");
|
|
746
|
+
const embedCheck = await verifySemanticSearch(httpUrl, agentId, keysDir);
|
|
747
|
+
if (embedCheck.state === "ok") {
|
|
748
|
+
console.log(`Semantic search operational ✓ ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${embedCheck.score.toFixed(2)})`)}`);
|
|
749
|
+
}
|
|
750
|
+
else if (embedCheck.state === "degraded") {
|
|
751
|
+
// LOUD — embeddings not loaded. Same message class as `flair doctor`.
|
|
752
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Semantic search DEGRADED")} — embeddings not loaded; recall-by-meaning will NOT work.`);
|
|
753
|
+
console.log(` ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
|
|
754
|
+
console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
|
|
755
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: install without sudo (see README Quick Start), then:")} flair restart && flair doctor`);
|
|
756
|
+
}
|
|
757
|
+
else if (embedCheck.state === "failed") {
|
|
758
|
+
// flair#1501: the instance rejected the probe's signature. init just
|
|
759
|
+
// registered this agent, so this is a genuine auth defect, not a
|
|
760
|
+
// missing identity — surface it loudly with the signer named.
|
|
761
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Semantic search probe rejected")} — ${embedCheck.detail}.`);
|
|
762
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: register this key on the instance (`flair agent add <id>`) or pass --agent <a registered agent id>.")}`);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
|
|
766
|
+
}
|
|
767
|
+
// Verify the audit log ACTUALLY records (flair#970) — a positive
|
|
768
|
+
// control, not a flag read: `describe_table` reports `audit: true` on
|
|
769
|
+
// nodes whose audit trail is empty (base-copy elision, harper#2212).
|
|
770
|
+
// Same surface as the semantic-search check above.
|
|
771
|
+
console.log("Verifying audit log...");
|
|
772
|
+
const auditCheck = await verifyAuditLog(httpUrl, agentId, keysDir, `http://127.0.0.1:${opsPort}`, adminUser, adminPass);
|
|
773
|
+
if (auditCheck.state === "ok") {
|
|
774
|
+
// Present tense ONLY: the probe proves current recording, never
|
|
775
|
+
// historical completeness — see AuditVerifyResult's doc comment.
|
|
776
|
+
console.log(`Audit log: recording (verified now) ✓ ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
|
|
777
|
+
}
|
|
778
|
+
else if (auditCheck.state === "degraded") {
|
|
779
|
+
if (auditCheck.cause === "disabled") {
|
|
780
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log DISABLED")} — ${auditCheck.detail}.`);
|
|
781
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log NOT RECORDING")} — ${auditCheck.detail}.`);
|
|
785
|
+
console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
|
|
786
|
+
console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
|
|
787
|
+
console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
else if (auditCheck.state === "failed") {
|
|
791
|
+
// Same loud discipline as the semantic-search probe above (flair#1501).
|
|
792
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log probe rejected")} — ${auditCheck.detail}.`);
|
|
793
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: register this key on the instance (`flair agent add <id>`) or pass --agent <a registered agent id>.")}`);
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
// An unrun check must not look like a pass.
|
|
797
|
+
console.log(`${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditCheck.detail})`);
|
|
798
|
+
}
|
|
799
|
+
// Output — admin password printed once, never written to disk
|
|
800
|
+
console.log("\n✅ Flair initialized successfully");
|
|
801
|
+
console.log(` Agent ID: ${agentId}`);
|
|
802
|
+
console.log(` Flair URL: ${httpUrl}`);
|
|
803
|
+
console.log(` Private key: ${privPath}`);
|
|
804
|
+
// Display admin credentials when password was generated or from a file
|
|
805
|
+
// Do NOT display when from env (to avoid showing the env var value)
|
|
806
|
+
if (passwordSource !== "env" && !alreadyRunning) {
|
|
807
|
+
const passDisplay = passwordSource === "file"
|
|
808
|
+
? opts.adminPassFile ?? "(file path)"
|
|
809
|
+
: "~/.flair/admin-pass";
|
|
810
|
+
console.log(`\n ┌─────────────────────────────────────────────────┐`);
|
|
811
|
+
console.log(` │ Harper admin credentials (save these now): │`);
|
|
812
|
+
console.log(` │ │`);
|
|
813
|
+
console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
|
|
814
|
+
console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
|
|
815
|
+
console.log(` │ │`);
|
|
816
|
+
console.log(` │ ⚠️ The password won't be shown again. │`);
|
|
817
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
818
|
+
}
|
|
819
|
+
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
820
|
+
// ── First-run soul setup ──────────────────────────────────────────────
|
|
821
|
+
// Interactive wizard to set initial personality (see runSoulWizard).
|
|
822
|
+
// Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
|
|
823
|
+
//
|
|
824
|
+
// Non-TTY / --skip-soul used to seed placeholder text like
|
|
825
|
+
// "AI assistant [default]" — it leaked into bootstrap output and
|
|
826
|
+
// confused users. Now those paths leave the soul empty and nudge the
|
|
827
|
+
// user toward `flair soul set` / `flair doctor` instead.
|
|
828
|
+
if (!opts.skipSoul && process.stdin.isTTY) {
|
|
829
|
+
const soulEntries = await runSoulWizard(agentId);
|
|
830
|
+
if (soulEntries.length > 0) {
|
|
831
|
+
console.log("");
|
|
832
|
+
for (const [key, value] of soulEntries) {
|
|
833
|
+
try {
|
|
834
|
+
await api("PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() }, { baseUrl: httpUrl, explicitAdminPass: adminPass, adminUser });
|
|
835
|
+
console.log(` ✓ soul:${key} set`);
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
839
|
+
console.warn(` ⚠ soul:${key} failed: ${message}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
console.log(`\n ${soulEntries.length} soul entries saved.`);
|
|
843
|
+
console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
console.log(`\n No soul entries saved. Add later with:`);
|
|
847
|
+
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
848
|
+
console.log(` Or run \`flair doctor\` anytime for a nudge.`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
|
|
853
|
+
console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
|
|
854
|
+
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
855
|
+
}
|
|
856
|
+
// ── MCP client wiring ────────────────────────────────────────────────
|
|
857
|
+
// The full one-command front door: detect installed MCP clients and wire
|
|
858
|
+
// each to the zero-install `npx -y @tpsdev-ai/flair-mcp@<version>` server
|
|
859
|
+
// (pinned — see mcpServerSpec()). Claude
|
|
860
|
+
// Code is auto-wired into ~/.claude.json (the only client the CLI can
|
|
861
|
+
// safely modify); other clients get copy-paste snippets. `--no-mcp`
|
|
862
|
+
// skips wiring entirely; `--client <name>` targets one client; the
|
|
863
|
+
// default (no flag) wires every detected client.
|
|
864
|
+
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
865
|
+
// `wired` is the load-bearing field (flair#906): it separates "a config
|
|
866
|
+
// file was actually written" from "we printed something and moved on".
|
|
867
|
+
// Both outcomes used to be pushed here indistinguishably as far as the
|
|
868
|
+
// user was concerned, so `--client all` reported success for a client it
|
|
869
|
+
// had not wired.
|
|
870
|
+
const wiringResults = [];
|
|
871
|
+
// Human-readable labels + the clients `--client all` passed over because
|
|
872
|
+
// they aren't installed, so the closing summary can account for every
|
|
873
|
+
// client the user asked for rather than only the ones we tried.
|
|
874
|
+
const clientLabels = new Map();
|
|
875
|
+
const skippedUndetected = [];
|
|
876
|
+
if (!noMcp && clientOpt !== "none") {
|
|
877
|
+
// A spec we cannot pin is a security property quietly downgraded, so
|
|
878
|
+
// say so BEFORE writing it and again in the summary below (flair#907).
|
|
879
|
+
// stderr: this must survive `flair init | tee`, and it is a warning,
|
|
880
|
+
// not part of the command's normal output.
|
|
881
|
+
const pinWarning = unpinnedSpecWarning();
|
|
882
|
+
if (pinWarning) {
|
|
883
|
+
console.error("");
|
|
884
|
+
for (const line of pinWarning.split("\n"))
|
|
885
|
+
console.error(` ⚠ ${line}`);
|
|
886
|
+
}
|
|
887
|
+
// Determine which clients to wire.
|
|
888
|
+
let clients = detectClients();
|
|
889
|
+
if (selectedClients.length > 0) {
|
|
890
|
+
clients = clients.filter(c => selectedClients.includes(c.id));
|
|
891
|
+
}
|
|
892
|
+
for (const c of clients)
|
|
893
|
+
clientLabels.set(c.id, c.label);
|
|
894
|
+
const detected = clients.filter(c => c.detected);
|
|
895
|
+
if (!clientOpt) {
|
|
896
|
+
if (detected.length === 0) {
|
|
897
|
+
console.log("\n No MCP clients detected. Run with --client <name> to wire a specific client.");
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
console.log(`\n Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const toWire = clientOpt === "all"
|
|
904
|
+
? clients.filter(c => c.detected).map(c => c.id)
|
|
905
|
+
: selectedClients.length > 0
|
|
906
|
+
? selectedClients
|
|
907
|
+
: clients.filter(c => c.detected).map(c => c.id);
|
|
908
|
+
// `--client all` is a promise about every client, so the ones it
|
|
909
|
+
// passed over have to be accounted for too — silently omitting them
|
|
910
|
+
// is how "all" reports success for work it never did (flair#906).
|
|
911
|
+
if (clientOpt === "all") {
|
|
912
|
+
for (const c of clients) {
|
|
913
|
+
if (!c.detected)
|
|
914
|
+
skippedUndetected.push(c.label);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
for (const clientId of toWire) {
|
|
918
|
+
if (clientId === "claude-code") {
|
|
919
|
+
// Claude Code gets real auto-wiring into ~/.claude.json (zero-install
|
|
920
|
+
// npx form; matches the snippets everywhere else). Other clients only
|
|
921
|
+
// get printed instructions — the CLI can't safely edit their configs.
|
|
922
|
+
const claudeJsonPath = join(homedir(), ".claude.json");
|
|
923
|
+
const flairMcpConfig = {
|
|
924
|
+
type: "stdio",
|
|
925
|
+
command: "npx",
|
|
926
|
+
args: ["-y", mcpServerSpec()],
|
|
927
|
+
// flair#718 authorship-provenance: each client's wired env block
|
|
928
|
+
// gets its OWN FLAIR_CLIENT label (never the shared mcpEnv
|
|
929
|
+
// object directly — that would stamp the same label into every
|
|
930
|
+
// client's config) so writes from THIS client's proxy stamp
|
|
931
|
+
// provenance.claimed.client = "claude-code".
|
|
932
|
+
env: { ...mcpEnv, FLAIR_CLIENT: "claude-code" },
|
|
933
|
+
};
|
|
934
|
+
// ~/.claude.json exists once Claude Code has been RUN, not once it
|
|
935
|
+
// is installed — so gating the write on it skipped every user who
|
|
936
|
+
// installed Claude Code and Flair in the same sitting (flair#906).
|
|
937
|
+
// The file is Claude Code's own and creating it with a single
|
|
938
|
+
// `mcpServers` key is exactly what `claude mcp add` does, so an
|
|
939
|
+
// absent file is created rather than turned into a printed snippet
|
|
940
|
+
// the user has to notice and act on.
|
|
941
|
+
try {
|
|
942
|
+
const claudeJsonExisted = existsSync(claudeJsonPath);
|
|
943
|
+
const claudeJson = claudeJsonExisted
|
|
944
|
+
? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
|
|
945
|
+
: {};
|
|
946
|
+
const existing = claudeJson.mcpServers?.flair;
|
|
947
|
+
const currentSpec = mcpServerSpec();
|
|
948
|
+
const existingArgs = existing?.args;
|
|
949
|
+
const argsMatch = Array.isArray(existingArgs) && existingArgs.includes(currentSpec);
|
|
950
|
+
const urlAgentMatch = existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId;
|
|
951
|
+
// flair#1135: the pin in `args` must match the current mcpServerSpec().
|
|
952
|
+
// A matching pin stays a no-op (idempotent); only a stale pin triggers a re-write.
|
|
953
|
+
if (urlAgentMatch && argsMatch) {
|
|
954
|
+
console.log(` ✓ Claude Code already wired in ~/.claude.json`);
|
|
955
|
+
wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
claudeJson.mcpServers = claudeJson.mcpServers || {};
|
|
959
|
+
claudeJson.mcpServers.flair = flairMcpConfig;
|
|
960
|
+
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
961
|
+
const action = urlAgentMatch ? "refreshed pin in ~/.claude.json"
|
|
962
|
+
: claudeJsonExisted ? "wired in ~/.claude.json"
|
|
963
|
+
: "wired in ~/.claude.json (created)";
|
|
964
|
+
console.log(` ✓ Claude Code ${action} (restart Claude Code to pick it up)`);
|
|
965
|
+
wiringResults.push({
|
|
966
|
+
client: "claude-code",
|
|
967
|
+
message: urlAgentMatch ? "refreshed pin in ~/.claude.json"
|
|
968
|
+
: claudeJsonExisted ? "wired ~/.claude.json"
|
|
969
|
+
: "created and wired ~/.claude.json",
|
|
970
|
+
wired: true,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
catch (err) {
|
|
975
|
+
// Only a genuine read/parse/write failure lands here now (bad
|
|
976
|
+
// permissions, malformed existing JSON) — never merely "the file
|
|
977
|
+
// does not exist yet".
|
|
978
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
979
|
+
console.log(` ${render.icons.warn} Claude Code: could not write ~/.claude.json (${reason})`);
|
|
980
|
+
console.log(` MCP config (add manually to ~/.claude.json):`);
|
|
981
|
+
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
982
|
+
wiringResults.push({ client: "claude-code", message: `snippet printed (${reason})`, wired: false });
|
|
983
|
+
}
|
|
984
|
+
// ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
|
|
985
|
+
// The MCP block alone isn't a working setup — Claude Code also needs
|
|
986
|
+
// the bootstrap instruction in CLAUDE.md, or it never calls
|
|
987
|
+
// mcp__flair__bootstrap and memory silently does nothing. Applied
|
|
988
|
+
// automatically here (same "just do it" shape as the MCP block
|
|
989
|
+
// above); --skip-claude-md opts out and prints the exact line to
|
|
990
|
+
// add by hand instead.
|
|
991
|
+
const claudeMdResult = applyOrReportClaudeMdBootstrap(process.cwd(), homedir(), !!opts.skipClaudeMd);
|
|
992
|
+
console.log(` ${claudeMdResult.ok ? "✓" : "•"} ${claudeMdResult.message}`);
|
|
993
|
+
if (claudeMdResult.hint) {
|
|
994
|
+
for (const line of claudeMdResult.hint.split("\n"))
|
|
995
|
+
console.log(` ${line}`);
|
|
996
|
+
}
|
|
997
|
+
// ── SessionStart hook (flair#597) ─────────────────────────────────
|
|
998
|
+
// Auto-recall on session start needs this hook wired into
|
|
999
|
+
// ~/.claude/settings.json — without it, mcp__flair__bootstrap only
|
|
1000
|
+
// ever runs if the agent remembers to call it itself.
|
|
1001
|
+
// --skip-hook opts out and prints the exact JSON to add by hand.
|
|
1002
|
+
const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook);
|
|
1003
|
+
console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
|
|
1004
|
+
if (hookResult.hint) {
|
|
1005
|
+
for (const line of hookResult.hint.split("\n"))
|
|
1006
|
+
console.log(` ${line}`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
let result;
|
|
1011
|
+
// flair#718 authorship-provenance — see the claude-code branch's
|
|
1012
|
+
// identical comment above: FLAIR_CLIENT is per-client, so it's
|
|
1013
|
+
// added at each call site rather than baked into the shared mcpEnv.
|
|
1014
|
+
switch (clientId) {
|
|
1015
|
+
case "codex":
|
|
1016
|
+
result = wireCodex({ ...mcpEnv, FLAIR_CLIENT: "codex" });
|
|
1017
|
+
break;
|
|
1018
|
+
case "gemini":
|
|
1019
|
+
result = wireGemini({ ...mcpEnv, FLAIR_CLIENT: "gemini" });
|
|
1020
|
+
break;
|
|
1021
|
+
case "cursor":
|
|
1022
|
+
result = wireCursor({ ...mcpEnv, FLAIR_CLIENT: "cursor" });
|
|
1023
|
+
break;
|
|
1024
|
+
case "antigravity":
|
|
1025
|
+
result = wireAntigravity({ ...mcpEnv, FLAIR_CLIENT: "antigravity" });
|
|
1026
|
+
break;
|
|
1027
|
+
// pi is a NATIVE EXTENSION, not an MCP client (flair#1342):
|
|
1028
|
+
// wirePi edits ~/.pi/agent/settings.json `packages`, and pi
|
|
1029
|
+
// settings carry no env block — no FLAIR_CLIENT to stamp; the
|
|
1030
|
+
// wire message tells the user what to export at pi launch.
|
|
1031
|
+
case "pi":
|
|
1032
|
+
result = wirePi(mcpEnv);
|
|
1033
|
+
break;
|
|
1034
|
+
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
1035
|
+
}
|
|
1036
|
+
wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
|
|
1037
|
+
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
1038
|
+
// Codex SessionStart hook (flair#1148 / #1439) — the hook is not
|
|
1039
|
+
// optional on Codex (no CLAUDE.md alternative). Init is the
|
|
1040
|
+
// consent to set up the client, same as the Claude Code hook
|
|
1041
|
+
// applied above. --skip-hook opts out and prints the JSON.
|
|
1042
|
+
if (clientId === "codex" && result.ok) {
|
|
1043
|
+
const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook, hookSettingsPath(homedir(), "codex"));
|
|
1044
|
+
console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
|
|
1045
|
+
if (hookResult.hint) {
|
|
1046
|
+
for (const line of hookResult.hint.split("\n"))
|
|
1047
|
+
console.log(` ${line}`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// ── Smoke-test the MCP server ────────────────────────────────────────
|
|
1054
|
+
// Launch flair-mcp and confirm it answers a JSON-RPC initialize over
|
|
1055
|
+
// stdio. Best-effort: failures warn but never fail the command. Skipped
|
|
1056
|
+
// with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
|
|
1057
|
+
// pi doesn't run flair-mcp (native extension, flair#1342), so a pi-only
|
|
1058
|
+
// wiring has nothing this smoke test exercises — spawning it anyway
|
|
1059
|
+
// would render a green "MCP server responded" for a setup that never
|
|
1060
|
+
// starts an MCP server.
|
|
1061
|
+
const wiredAnyMcpClient = wiringResults.some((r) => r.client !== "pi");
|
|
1062
|
+
if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0 && wiredAnyMcpClient) {
|
|
1063
|
+
console.log("\n Smoke-testing MCP server...");
|
|
1064
|
+
try {
|
|
1065
|
+
// Same spec that gets WIRED above — the smoke test must exercise the
|
|
1066
|
+
// exact version the user will run, not whatever npm resolves latest to.
|
|
1067
|
+
const mcpProc = spawn("npx", ["-y", mcpServerSpec()], {
|
|
1068
|
+
env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
|
|
1069
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1070
|
+
});
|
|
1071
|
+
const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-init", version: "1.0.0" } } });
|
|
1072
|
+
mcpProc.stdin.write(initMsg + "\n");
|
|
1073
|
+
mcpProc.stdin.end();
|
|
1074
|
+
let stdout = "";
|
|
1075
|
+
mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
1076
|
+
// A single timer drives the timeout AND cleanup. It MUST be cleared on
|
|
1077
|
+
// settle — an un-cleared setTimeout is a live handle that keeps Node's
|
|
1078
|
+
// event loop alive (the ~60s phantom hang after `flair init` printed
|
|
1079
|
+
// success: the smoke timer + the lingering npx child both pinned the
|
|
1080
|
+
// loop). We clear it on every exit path below.
|
|
1081
|
+
let smokeTimer = null;
|
|
1082
|
+
await new Promise((resolve, reject) => {
|
|
1083
|
+
const settle = (fn) => {
|
|
1084
|
+
if (smokeTimer) {
|
|
1085
|
+
clearTimeout(smokeTimer);
|
|
1086
|
+
smokeTimer = null;
|
|
1087
|
+
}
|
|
1088
|
+
fn();
|
|
1089
|
+
};
|
|
1090
|
+
mcpProc.on("exit", (code) => {
|
|
1091
|
+
settle(() => {
|
|
1092
|
+
if (code === 0 && stdout.length > 0)
|
|
1093
|
+
resolve();
|
|
1094
|
+
else
|
|
1095
|
+
reject(new Error(`MCP server exited with code ${code}`));
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
mcpProc.on("error", (err) => settle(() => reject(err)));
|
|
1099
|
+
smokeTimer = setTimeout(() => settle(() => { mcpProc.kill("SIGKILL"); reject(new Error("MCP smoke test timed out")); }), 15_000);
|
|
1100
|
+
});
|
|
1101
|
+
try {
|
|
1102
|
+
const lines = stdout.split("\n").filter(l => l.trim());
|
|
1103
|
+
for (const line of lines) {
|
|
1104
|
+
const parsed = JSON.parse(line);
|
|
1105
|
+
if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
|
|
1106
|
+
console.log(" ✓ MCP server responded");
|
|
1107
|
+
break;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
console.log(" ⚠ MCP server responded but response could not be parsed");
|
|
1113
|
+
}
|
|
1114
|
+
finally {
|
|
1115
|
+
// Reap the child even on the resolve path: the MCP server exits on
|
|
1116
|
+
// stdin close, but the `npx` wrapper can linger holding the loop.
|
|
1117
|
+
// SIGKILL is safe — we already have the response we need.
|
|
1118
|
+
try {
|
|
1119
|
+
if (mcpProc.exitCode === null)
|
|
1120
|
+
mcpProc.kill("SIGKILL");
|
|
1121
|
+
}
|
|
1122
|
+
catch { /* already gone */ }
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
catch (err) {
|
|
1126
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1127
|
+
console.log(` ⚠ MCP smoke test failed: ${message}`);
|
|
1128
|
+
console.log(" Use --skip-smoke to bypass.");
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// ── MCP wiring summary (flair#906) ───────────────────────────────────
|
|
1132
|
+
// LAST thing init prints, deliberately. Every fact below was already
|
|
1133
|
+
// available mid-run, but a client that was not wired appeared only as a
|
|
1134
|
+
// snippet in the middle of a wall of output, after a success line — so
|
|
1135
|
+
// the user's next action was to open their client and find nothing
|
|
1136
|
+
// there, with no reason to suspect init. A client the user asked for and
|
|
1137
|
+
// did NOT get must still be on screen when the command finishes.
|
|
1138
|
+
if (!noMcp && clientOpt !== "none") {
|
|
1139
|
+
// The pin warning repeats here for the same reason the summary exists:
|
|
1140
|
+
// a warning only in scrollback is a warning the user acts on never.
|
|
1141
|
+
const summaryLines = renderWiringSummary(wiringResults, {
|
|
1142
|
+
labels: clientLabels,
|
|
1143
|
+
skippedUndetected,
|
|
1144
|
+
rewireHint: `flair init --agent ${agentId} --client all`,
|
|
1145
|
+
unpinned: unpinnedSpecWarning() !== null,
|
|
1146
|
+
});
|
|
1147
|
+
for (const line of summaryLines) {
|
|
1148
|
+
const icon = line.level === "ok" ? render.icons.ok :
|
|
1149
|
+
line.level === "error" ? render.icons.error :
|
|
1150
|
+
line.level === "warn" ? render.icons.warn :
|
|
1151
|
+
line.level === "muted" ? render.icons.bullet :
|
|
1152
|
+
null;
|
|
1153
|
+
if (line.level === "heading")
|
|
1154
|
+
console.log(`\n ${line.text}`);
|
|
1155
|
+
else
|
|
1156
|
+
console.log(` ${icon} ${line.text}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1162
|
+
console.log("\n✅ Flair initialized (no agent registered)");
|
|
1163
|
+
console.log(` Flair URL: ${httpUrl}`);
|
|
1164
|
+
// Display admin credentials when password was generated or from a file
|
|
1165
|
+
// Do NOT display when from env (to avoid showing the env var value)
|
|
1166
|
+
if (passwordSource !== "env" && !alreadyRunning) {
|
|
1167
|
+
const passDisplay = passwordSource === "file"
|
|
1168
|
+
? opts.adminPassFile ?? "(file path)"
|
|
1169
|
+
: "~/.flair/admin-pass";
|
|
1170
|
+
console.log(`\n ┌─────────────────────────────────────────────────┐`);
|
|
1171
|
+
console.log(` │ Harper admin credentials (save these now): │`);
|
|
1172
|
+
console.log(` │ │`);
|
|
1173
|
+
console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
|
|
1174
|
+
console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
|
|
1175
|
+
console.log(` │ │`);
|
|
1176
|
+
console.log(` │ ⚠️ The password won't be shown again. │`);
|
|
1177
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
1178
|
+
}
|
|
1179
|
+
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
1180
|
+
// flair#802a: a non-interactive shell (CI, Docker, an unattended setup
|
|
1181
|
+
// script) that omits --agent lands here with NO indication that agent
|
|
1182
|
+
// registration, MCP client wiring, and the smoke test were all skipped
|
|
1183
|
+
// — the run exits 0 and looks complete. In a real TTY the missing
|
|
1184
|
+
// --agent is usually obvious from the command the user just typed;
|
|
1185
|
+
// non-interactively it's easy to never notice until something that
|
|
1186
|
+
// needed the agent (recall, an MCP client) mysteriously doesn't work.
|
|
1187
|
+
if (!process.stdin.isTTY) {
|
|
1188
|
+
console.log(`\n ℹ Non-interactive shell: skipped agent registration, MCP client wiring, and the smoke test.`);
|
|
1189
|
+
console.log(` Complete setup with: flair init --agent <id> --client all`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// All init work is genuinely done at this point: Harper is installed +
|
|
1193
|
+
// running (detached, unref'd — survives this process exiting), the agent is
|
|
1194
|
+
// registered, semantic search is verified, MCP clients are wired, and the
|
|
1195
|
+
// smoke test ran. The MCP smoke subprocess can leave a lingering npx handle
|
|
1196
|
+
// that pins Node's event loop for ~60s after success ("rc=0 but doesn't
|
|
1197
|
+
// return"). We've cleared/unref'd the known timers above; exit explicitly so
|
|
1198
|
+
// the prompt returns in a couple seconds regardless of any stray handle. The
|
|
1199
|
+
// running Harper instance is unaffected.
|
|
1200
|
+
await new Promise((r) => process.stdout.write("", () => r()));
|
|
1201
|
+
process.exit(0);
|
|
1202
|
+
});
|
|
1203
|
+
}
|