@tpsdev-ai/flair 0.53.0 → 0.54.2
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 +1791 -15648
- 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 +1665 -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 +931 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1628 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/engine-version.js +12 -4
- package/dist/fabric-npm-install.js +87 -0
- package/dist/fabric-upgrade.js +30 -15
- 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 +5 -4
- package/dist/lib/npm-registry.js +578 -0
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -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 +48 -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/resources/tool-descriptors/index.js +669 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/dist/version-check.js +29 -8
- 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 +20 -6
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/package.json +4 -4
- package/schemas/agent.graphql +15 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as render from "../render.js";
|
|
2
|
+
import { sweepFleet, renderFleetSweepTable, } from "../fleet-verify.js";
|
|
3
|
+
let cli;
|
|
4
|
+
/** Bind shared CLI helpers. cli.ts calls this immediately before register(program). */
|
|
5
|
+
export function bindCli(fns) {
|
|
6
|
+
cli = fns;
|
|
7
|
+
}
|
|
8
|
+
function resolveFabricCredentials(opts) {
|
|
9
|
+
return cli.resolveFabricCredentials(opts);
|
|
10
|
+
}
|
|
11
|
+
export function register(program) {
|
|
12
|
+
// ─── flair fleet ────────────────────────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// Fabric fleet operations (flair#636). `flair deploy` / `flair upgrade
|
|
15
|
+
// --target` already run this sweep automatically post-deploy (skippable
|
|
16
|
+
// with --no-fleet-verify) — this is the standalone entry point for running
|
|
17
|
+
// it independently, e.g. as a periodic health check or before a rolling
|
|
18
|
+
// restart step (see the flair#636 decision comment: this sweep is the gate
|
|
19
|
+
// between peers during a rolling restart, not the restart mechanism itself).
|
|
20
|
+
const fleet = program.command("fleet").description("Fabric fleet operations (post-deploy convergence verification)");
|
|
21
|
+
fleet
|
|
22
|
+
.command("verify")
|
|
23
|
+
.description("Sweep a Fabric origin + its known federation peers for version/health convergence")
|
|
24
|
+
.requiredOption("--target <url>", "Fabric URL to verify (the origin node)")
|
|
25
|
+
.option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER — preferred; inline leaks to ps/shell history)")
|
|
26
|
+
.option("--fabric-password <pass>", "Fabric admin password (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
|
|
27
|
+
.option("--fabric-password-file <path>", "Read the Fabric admin password from a mode-0600 file (keeps it out of argv and env)")
|
|
28
|
+
.option("--expect-version <semver>", "Version every node must report (default: the origin's own reported version — a self-consistency check)")
|
|
29
|
+
.option("--timeout <ms>", "Per-node /Health poll timeout in ms", "60000")
|
|
30
|
+
.option("--json", "Emit JSON (also: pipe + FLAIR_OUTPUT=json)")
|
|
31
|
+
.addHelpText("after", `
|
|
32
|
+
Exit codes:
|
|
33
|
+
0 all probed nodes verified (unverifiable peers — no endpoint on file —
|
|
34
|
+
are listed as a warning and do not fail the run)
|
|
35
|
+
1 origin failed (unreachable, unauthenticated, or wrong version)
|
|
36
|
+
2 a reachable node diverged (wrong version) — NOT converged
|
|
37
|
+
3 a reachable peer was unreachable or rejected auth (not unverifiable)
|
|
38
|
+
|
|
39
|
+
"peer" here means a Flair federation peer (GET /FederationPeers on the
|
|
40
|
+
origin) — NOT Harper's own cluster-replication nodes, which the OSS
|
|
41
|
+
harper build this CLI ships does not expose (cluster_status is
|
|
42
|
+
a harper-pro-only operation). A Fabric replica that was never
|
|
43
|
+
federation-paired (\`flair federation pair\`) is invisible to this sweep —
|
|
44
|
+
see src/fleet-verify.ts's file header for the full caveat.`)
|
|
45
|
+
.action(async (opts) => {
|
|
46
|
+
// Single source of truth for cred resolution + shell-history warnings,
|
|
47
|
+
// shared with `flair upgrade --target` and `flair deploy`.
|
|
48
|
+
const { fabricUser, fabricPassword, warnings } = resolveFabricCredentials(opts);
|
|
49
|
+
for (const w of warnings)
|
|
50
|
+
console.error(render.wrap(render.c.dim, w));
|
|
51
|
+
if (!fabricUser || !fabricPassword) {
|
|
52
|
+
console.error(render.wrap(render.c.red, "flair fleet verify: credentials required"));
|
|
53
|
+
console.error(" set FABRIC_USER + FABRIC_PASSWORD env, or --fabric-password-file, or (discouraged) --fabric-user/--fabric-password inline");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const result = await sweepFleet({
|
|
57
|
+
target: opts.target,
|
|
58
|
+
fabricUser,
|
|
59
|
+
fabricPassword,
|
|
60
|
+
expectVersion: opts.expectVersion,
|
|
61
|
+
timeoutMs: Number(opts.timeout ?? 60_000),
|
|
62
|
+
});
|
|
63
|
+
const mode = render.resolveOutputMode(opts);
|
|
64
|
+
if (mode === "json") {
|
|
65
|
+
console.log(render.asJSON(result));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.log(render.wrap(render.c.bold, `Fleet verify — ${result.target}`));
|
|
69
|
+
console.log(renderFleetSweepTable(result));
|
|
70
|
+
}
|
|
71
|
+
process.exit(result.exitCode);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
let cli;
|
|
2
|
+
/** Bind the cli-locals this module depends on. */
|
|
3
|
+
export function bindCli(fns) {
|
|
4
|
+
cli = fns;
|
|
5
|
+
}
|
|
6
|
+
function resolveHttpPort(...args) {
|
|
7
|
+
return cli.resolveHttpPort(...args);
|
|
8
|
+
}
|
|
9
|
+
function resolveOpsPort(...args) {
|
|
10
|
+
return cli.resolveOpsPort(...args);
|
|
11
|
+
}
|
|
12
|
+
function resolveAdminUser(...args) {
|
|
13
|
+
return cli.resolveAdminUser(...args);
|
|
14
|
+
}
|
|
15
|
+
export function register(program) {
|
|
16
|
+
// ─── flair grant / revoke ─────────────────────────────────────────────────────
|
|
17
|
+
program
|
|
18
|
+
.command("grant <from-agent> <to-agent>")
|
|
19
|
+
.description("Grant an agent read access to another agent's memories")
|
|
20
|
+
.option("--scope <scope>", "Grant scope: read or search", "read")
|
|
21
|
+
.option("--port <port>", "Harper HTTP port")
|
|
22
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
23
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
24
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
25
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys (for from-agent Ed25519 auth)")
|
|
26
|
+
.action(async (fromAgent, toAgent, opts) => {
|
|
27
|
+
const httpPort = resolveHttpPort(opts);
|
|
28
|
+
const opsPort = resolveOpsPort(opts);
|
|
29
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
30
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
31
|
+
const scope = opts.scope ?? "read";
|
|
32
|
+
if (!adminPass) {
|
|
33
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for grant");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
37
|
+
const grantId = `${fromAgent}:${toAgent}`;
|
|
38
|
+
const body = {
|
|
39
|
+
operation: "insert",
|
|
40
|
+
database: "flair",
|
|
41
|
+
table: "MemoryGrant",
|
|
42
|
+
records: [{
|
|
43
|
+
id: grantId,
|
|
44
|
+
ownerId: fromAgent,
|
|
45
|
+
granteeId: toAgent,
|
|
46
|
+
scope,
|
|
47
|
+
createdAt: new Date().toISOString(),
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
signal: AbortSignal.timeout(10_000),
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const text = await res.text().catch(() => "");
|
|
58
|
+
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists")) {
|
|
59
|
+
console.log(`ℹ️ Grant already exists: '${toAgent}' can already read '${fromAgent}'s memories`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Failed to create grant (${res.status}): ${text}`);
|
|
63
|
+
}
|
|
64
|
+
console.log(`✅ Grant created: '${toAgent}' can now read '${fromAgent}'s memories`);
|
|
65
|
+
console.log(` ID: ${grantId}`);
|
|
66
|
+
console.log(` Scope: ${scope}`);
|
|
67
|
+
});
|
|
68
|
+
program
|
|
69
|
+
.command("revoke <from-agent> <to-agent>")
|
|
70
|
+
.description("Revoke a memory grant between two agents")
|
|
71
|
+
.option("--port <port>", "Harper HTTP port")
|
|
72
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
73
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
74
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
75
|
+
.action(async (fromAgent, toAgent, opts) => {
|
|
76
|
+
const httpPort = resolveHttpPort(opts);
|
|
77
|
+
const opsPort = resolveOpsPort(opts);
|
|
78
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
79
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
80
|
+
if (!adminPass) {
|
|
81
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for revoke");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
85
|
+
const grantId = `${fromAgent}:${toAgent}`;
|
|
86
|
+
const body = {
|
|
87
|
+
operation: "delete",
|
|
88
|
+
database: "flair",
|
|
89
|
+
table: "MemoryGrant",
|
|
90
|
+
ids: [grantId],
|
|
91
|
+
};
|
|
92
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
signal: AbortSignal.timeout(10_000),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
const text = await res.text().catch(() => "");
|
|
100
|
+
if (res.status === 404 || text.includes("not found")) {
|
|
101
|
+
console.log(`ℹ️ No grant found: '${toAgent}' does not have access to '${fromAgent}'s memories`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`Failed to revoke grant (${res.status}): ${text}`);
|
|
105
|
+
}
|
|
106
|
+
console.log(`✅ Grant revoked: '${toAgent}' can no longer read '${fromAgent}'s memories`);
|
|
107
|
+
console.log(` Removed grant ID: ${grantId}`);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import * as render from "../render.js";
|
|
3
|
+
import { unpinnedSpecWarning } from "../lib/mcp-spec.js";
|
|
4
|
+
import { readClientMcpBlock } from "../doctor-client.js";
|
|
5
|
+
import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "../hook-install.js";
|
|
6
|
+
let cli;
|
|
7
|
+
/** Bind shared CLI helpers. cli.ts calls this immediately before register(program). */
|
|
8
|
+
export function bindCli(fns) {
|
|
9
|
+
cli = fns;
|
|
10
|
+
}
|
|
11
|
+
function resolveBaseUrl(opts) {
|
|
12
|
+
return cli.resolveBaseUrl(opts);
|
|
13
|
+
}
|
|
14
|
+
/** Register the `flair hook` command group. */
|
|
15
|
+
export function register(program) {
|
|
16
|
+
// ─── flair hook ──────────────────────────────────────────────────────────────
|
|
17
|
+
// Ambient memory via harness SessionStart hooks (flair#745, design record
|
|
18
|
+
// #719 — the "Paved-paths" round). `flair doctor --fix`/`flair init` already
|
|
19
|
+
// wire the same SessionStart hook as a side effect of a bigger flow; this is
|
|
20
|
+
// the standalone, symmetric command family (install/uninstall/status) an
|
|
21
|
+
// operator or a headless/scheduled setup script can run on its own. All the
|
|
22
|
+
// mutation logic (fail-closed on malformed settings.json, idempotent merge,
|
|
23
|
+
// dry-run delta, symmetric removal) lives in src/hook-install.ts — this
|
|
24
|
+
// section is pure CLI plumbing: option parsing, default resolution, and
|
|
25
|
+
// rendering the pure functions' results.
|
|
26
|
+
function resolveHookFlairUrl(opts, homeDir, harness) {
|
|
27
|
+
return (opts.url ||
|
|
28
|
+
process.env.FLAIR_TARGET ||
|
|
29
|
+
process.env.FLAIR_URL ||
|
|
30
|
+
readClientMcpBlock(harness, homeDir).flairUrl ||
|
|
31
|
+
(harness !== "claude-code" ? readClientMcpBlock("claude-code", homeDir).flairUrl : undefined) ||
|
|
32
|
+
resolveBaseUrl({}));
|
|
33
|
+
}
|
|
34
|
+
function requireSupportedHarness(raw) {
|
|
35
|
+
const name = raw || "claude-code";
|
|
36
|
+
if (!isSupportedHarness(name)) {
|
|
37
|
+
console.error(`Unknown harness '${name}'. Supported: ${SUPPORTED_HARNESSES.join(", ")}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
return name;
|
|
41
|
+
}
|
|
42
|
+
const hook = program.command("hook").description("Manage ambient-memory harness SessionStart hooks (flair#745)");
|
|
43
|
+
hook
|
|
44
|
+
.command("install")
|
|
45
|
+
.description("Wire the Flair SessionStart hook into the harness config so memory loads automatically at session start")
|
|
46
|
+
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
47
|
+
.option("--dry-run", "Print the exact JSON delta without writing")
|
|
48
|
+
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for this harness's MCP client)")
|
|
49
|
+
.option("--agent-id <id>", "Alias for --agent")
|
|
50
|
+
.option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else this harness's MCP wiring, else the local default)")
|
|
51
|
+
.option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
|
|
52
|
+
.action((opts) => {
|
|
53
|
+
const harness = requireSupportedHarness(opts.harness);
|
|
54
|
+
const home = homedir();
|
|
55
|
+
const agentId = resolveHookAgentId(opts, home, harness);
|
|
56
|
+
if (!agentId) {
|
|
57
|
+
console.error("No agent id known — pass --agent <id>, set FLAIR_AGENT_ID, or run `flair init` / `flair agent add` first.");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const flairUrl = resolveHookFlairUrl(opts, home, harness);
|
|
61
|
+
const dryRun = !!opts.dryRun;
|
|
62
|
+
if (opts.continuity) {
|
|
63
|
+
const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
64
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
65
|
+
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
66
|
+
if (result.backupPath) {
|
|
67
|
+
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
68
|
+
}
|
|
69
|
+
console.log("");
|
|
70
|
+
if (!result.ok)
|
|
71
|
+
process.exit(1);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
75
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
76
|
+
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
77
|
+
const pinWarning = unpinnedSpecWarning();
|
|
78
|
+
if (pinWarning && result.ok) {
|
|
79
|
+
for (const line of pinWarning.split("\n"))
|
|
80
|
+
console.error(` ⚠ ${line}`);
|
|
81
|
+
}
|
|
82
|
+
if (result.backupPath) {
|
|
83
|
+
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
84
|
+
}
|
|
85
|
+
if (result.delta) {
|
|
86
|
+
console.log(`\n ${render.wrap(render.c.dim, `${dryRun ? "would apply" : "applied"} (${result.delta.action}):`)}`);
|
|
87
|
+
console.log(render.asJSON(result.delta));
|
|
88
|
+
}
|
|
89
|
+
console.log("");
|
|
90
|
+
if (!result.ok)
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
|
93
|
+
hook
|
|
94
|
+
.command("uninstall")
|
|
95
|
+
.description("Remove the Flair SessionStart hook entry — only ours, everything else in the file is left untouched")
|
|
96
|
+
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
97
|
+
.option("--dry-run", "Print the exact JSON delta without writing")
|
|
98
|
+
.option("--continuity", "Remove the continuity capture hooks instead (PostToolUse + Stop — flair#1257)")
|
|
99
|
+
.action((opts) => {
|
|
100
|
+
const harness = requireSupportedHarness(opts.harness);
|
|
101
|
+
const home = homedir();
|
|
102
|
+
const dryRun = !!opts.dryRun;
|
|
103
|
+
if (opts.continuity) {
|
|
104
|
+
const result = uninstallContinuityHooks({ homeDir: home, harness, dryRun });
|
|
105
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
106
|
+
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
107
|
+
if (result.backupPath) {
|
|
108
|
+
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
109
|
+
}
|
|
110
|
+
console.log("");
|
|
111
|
+
if (!result.ok)
|
|
112
|
+
process.exit(1);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const result = uninstallHook({ homeDir: home, harness, dryRun });
|
|
116
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
117
|
+
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
118
|
+
if (result.backupPath) {
|
|
119
|
+
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
120
|
+
}
|
|
121
|
+
if (result.delta) {
|
|
122
|
+
console.log(`\n ${render.wrap(render.c.dim, `${dryRun ? "would apply" : "applied"} (${result.delta.action}):`)}`);
|
|
123
|
+
console.log(render.asJSON(result.delta));
|
|
124
|
+
}
|
|
125
|
+
console.log("");
|
|
126
|
+
if (!result.ok)
|
|
127
|
+
process.exit(1);
|
|
128
|
+
});
|
|
129
|
+
hook
|
|
130
|
+
.command("status")
|
|
131
|
+
.description("Show whether the SessionStart hook is wired, its shape, and which Flair instance it targets")
|
|
132
|
+
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
133
|
+
.action((opts) => {
|
|
134
|
+
const harness = requireSupportedHarness(opts.harness);
|
|
135
|
+
const home = homedir();
|
|
136
|
+
const status = hookStatus(home, harness);
|
|
137
|
+
// Continuity pair (flair#1257) — reported alongside the SessionStart
|
|
138
|
+
// status in every branch below. "absent" is NOT a failure: installing the
|
|
139
|
+
// pair is the opt-in, so absence renders as "not enabled".
|
|
140
|
+
const renderContinuity = () => {
|
|
141
|
+
// Continuity is Claude Code only. Do not tip `--continuity --harness
|
|
142
|
+
// <other>` — that writes Claude tool matchers into the wrong file.
|
|
143
|
+
if (!harnessSupportsContinuity(harness))
|
|
144
|
+
return;
|
|
145
|
+
const cont = continuityHookStatus(home, harness);
|
|
146
|
+
if (cont.state === "installed") {
|
|
147
|
+
console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
|
|
148
|
+
}
|
|
149
|
+
else if (cont.state === "absent") {
|
|
150
|
+
console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, `(opt-in: ${hookInstallHint(harness, "--continuity")})`)}`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
|
|
154
|
+
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, `— re-run: ${hookInstallHint(harness, "--continuity")}`)}`);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
|
|
158
|
+
console.log(` ${render.wrap(render.c.dim, "Harness:")} ${status.harness}`);
|
|
159
|
+
console.log(` ${render.wrap(render.c.dim, "Config:")} ${status.path}`);
|
|
160
|
+
if (status.parseError) {
|
|
161
|
+
console.log(` ${render.icons.error} ${status.parseError}`);
|
|
162
|
+
console.log("");
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
if (!status.wired) {
|
|
166
|
+
console.log(` ${render.icons.error} not wired`);
|
|
167
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${hookInstallHint(status.harness)}`);
|
|
168
|
+
renderContinuity();
|
|
169
|
+
console.log("");
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
console.log(` ${status.correctShape ? render.icons.ok : render.icons.warn} wired${status.correctShape ? "" : " (unexpected shape — was it hand-edited?)"}`);
|
|
173
|
+
// flair#1325 — skip the URL line only when agentId was recovered
|
|
174
|
+
// (the installer form that omits FLAIR_URL). A wired correct-shape
|
|
175
|
+
// command with no env assignments still prints unknown, not a
|
|
176
|
+
// silent all-clear.
|
|
177
|
+
for (const line of hookStatusIdentityLines(status)) {
|
|
178
|
+
const label = line.label === "Agent" ? "Agent: " : "Flair URL:";
|
|
179
|
+
const value = line.value === HOOK_STATUS_UNPARSED ? render.wrap(render.c.dim, line.value) : line.value;
|
|
180
|
+
console.log(` ${render.wrap(render.c.dim, label)} ${value}`);
|
|
181
|
+
}
|
|
182
|
+
// flair#1007 — whether a command that stopped resolving would fail quietly
|
|
183
|
+
// or print an error on every session start.
|
|
184
|
+
if (status.silenced) {
|
|
185
|
+
console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
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`);
|
|
189
|
+
}
|
|
190
|
+
renderContinuity();
|
|
191
|
+
console.log("");
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as render from "../render.js";
|
|
3
|
+
import { resolveAdminUser } from "../lib/auth-resolve.js";
|
|
4
|
+
let cli;
|
|
5
|
+
/** Bind shared CLI helpers. cli.ts calls this immediately before register(program). */
|
|
6
|
+
export function bindCli(fns) {
|
|
7
|
+
cli = fns;
|
|
8
|
+
}
|
|
9
|
+
function api(...args) {
|
|
10
|
+
return cli.api(...args);
|
|
11
|
+
}
|
|
12
|
+
function resolveOpsPort(opts) {
|
|
13
|
+
return cli.resolveOpsPort(opts);
|
|
14
|
+
}
|
|
15
|
+
/** Register the `flair idp` command group. */
|
|
16
|
+
export function register(program) {
|
|
17
|
+
// ─── flair idp ───────────────────────────────────────────────────────────────
|
|
18
|
+
// XAA Enterprise IdP configuration (per FLAIR-XAA spec § 4).
|
|
19
|
+
const idp = program.command("idp").description("Manage enterprise IdP configurations (XAA)");
|
|
20
|
+
idp
|
|
21
|
+
.command("add")
|
|
22
|
+
.description("Register a trusted enterprise IdP")
|
|
23
|
+
.requiredOption("--name <name>", "Display name (e.g., 'Harper Corporate')")
|
|
24
|
+
.requiredOption("--issuer <url>", "IdP issuer URL (e.g., https://accounts.google.com)")
|
|
25
|
+
.requiredOption("--jwks-uri <url>", "JWKS endpoint URL")
|
|
26
|
+
.requiredOption("--client-id <id>", "Flair's client_id at this IdP")
|
|
27
|
+
.option("--required-domain <domain>", "Reject tokens without this domain (hd/tid claim)")
|
|
28
|
+
.option("--no-jit-provision", "Disable auto-creation of principals for new IdP users")
|
|
29
|
+
.option("--default-trust <tier>", "Trust tier for JIT principals", "unverified")
|
|
30
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
31
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
32
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
33
|
+
.action(async (opts) => {
|
|
34
|
+
const opsPort = resolveOpsPort(opts);
|
|
35
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
36
|
+
if (!adminPass) {
|
|
37
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const id = `idp_${randomUUID().slice(0, 8)}`;
|
|
41
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const record = {
|
|
44
|
+
id,
|
|
45
|
+
name: opts.name,
|
|
46
|
+
issuer: opts.issuer,
|
|
47
|
+
jwksUri: opts.jwksUri,
|
|
48
|
+
clientId: opts.clientId,
|
|
49
|
+
requiredDomain: opts.requiredDomain ?? null,
|
|
50
|
+
jitProvision: opts.jitProvision !== false,
|
|
51
|
+
defaultTrustTier: opts.defaultTrust ?? "unverified",
|
|
52
|
+
enabled: true,
|
|
53
|
+
createdAt: now,
|
|
54
|
+
updatedAt: now,
|
|
55
|
+
};
|
|
56
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
59
|
+
body: JSON.stringify({ operation: "upsert", database: "flair", table: "IdpConfig", records: [record] }),
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const text = await res.text().catch(() => "");
|
|
63
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
console.log(`✅ IdP '${opts.name}' registered (id: ${id})`);
|
|
67
|
+
console.log(` Issuer: ${opts.issuer}`);
|
|
68
|
+
console.log(` JWKS: ${opts.jwksUri}`);
|
|
69
|
+
console.log(` Client: ${opts.clientId}`);
|
|
70
|
+
if (opts.requiredDomain)
|
|
71
|
+
console.log(` Domain: ${opts.requiredDomain}`);
|
|
72
|
+
console.log(` JIT: ${opts.jitProvision !== false}`);
|
|
73
|
+
});
|
|
74
|
+
idp
|
|
75
|
+
.command("list")
|
|
76
|
+
.description("List configured IdPs")
|
|
77
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
78
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
79
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
80
|
+
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
81
|
+
.action(async (opts) => {
|
|
82
|
+
const opsPort = resolveOpsPort(opts);
|
|
83
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
84
|
+
if (!adminPass) {
|
|
85
|
+
console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
89
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
operation: "search_by_value",
|
|
94
|
+
schema: "flair",
|
|
95
|
+
table: "IdpConfig",
|
|
96
|
+
search_attribute: "id",
|
|
97
|
+
search_type: "starts_with",
|
|
98
|
+
search_value: "",
|
|
99
|
+
get_attributes: ["id", "name", "issuer", "requiredDomain", "jitProvision", "enabled", "createdAt"],
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
const text = await res.text().catch(() => "");
|
|
104
|
+
console.error(`${render.icons.error} ${res.status} ${text}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
const records = await res.json();
|
|
108
|
+
records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
109
|
+
const mode = render.resolveOutputMode(opts);
|
|
110
|
+
if (mode === "json") {
|
|
111
|
+
console.log(render.asJSON(records));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (records.length === 0) {
|
|
115
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no IdPs configured")}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
console.log(`${render.wrap(render.c.bold, String(records.length))} IdP${records.length === 1 ? "" : "s"}\n`);
|
|
119
|
+
for (const r of records) {
|
|
120
|
+
const enabled = r.enabled ? render.wrap(render.c.green, "enabled") : render.wrap(render.c.dim, "disabled");
|
|
121
|
+
console.log(`${render.wrap(render.c.bold, r.name ?? "?")} ${render.wrap(render.c.dim, `(${r.id})`)} ${render.wrap(render.c.dim, "—")} ${enabled}`);
|
|
122
|
+
console.log(render.kv("issuer", String(r.issuer ?? "—")));
|
|
123
|
+
if (r.requiredDomain)
|
|
124
|
+
console.log(render.kv("domain", String(r.requiredDomain)));
|
|
125
|
+
console.log(render.kv("JIT", String(r.jitProvision ?? true)));
|
|
126
|
+
console.log();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
idp
|
|
130
|
+
.command("remove <id>")
|
|
131
|
+
.description("Remove an IdP configuration")
|
|
132
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
133
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
134
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
135
|
+
.action(async (id, opts) => {
|
|
136
|
+
const opsPort = resolveOpsPort(opts);
|
|
137
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
138
|
+
if (!adminPass) {
|
|
139
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
|
|
143
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
146
|
+
body: JSON.stringify({ operation: "delete", database: "flair", table: "IdpConfig", hash_values: [id] }),
|
|
147
|
+
});
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
const text = await res.text().catch(() => "");
|
|
150
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
console.log(`✅ IdP '${id}' removed`);
|
|
154
|
+
});
|
|
155
|
+
idp
|
|
156
|
+
.command("test <id>")
|
|
157
|
+
.description("Test IdP connectivity (fetches JWKS)")
|
|
158
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
159
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
160
|
+
.action(async (id, opts) => {
|
|
161
|
+
const opsPort = resolveOpsPort(opts);
|
|
162
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
163
|
+
if (!adminPass) {
|
|
164
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
let cfg;
|
|
168
|
+
try {
|
|
169
|
+
cfg = await api("GET", `/IdpConfig/${id}`);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
console.error(`IdP '${id}' not found`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
console.log(`Testing IdP: ${cfg.name} (${cfg.issuer})`);
|
|
176
|
+
console.log(` JWKS endpoint: ${cfg.jwksUri}`);
|
|
177
|
+
try {
|
|
178
|
+
const jwksRes = await fetch(cfg.jwksUri, { signal: AbortSignal.timeout(10_000) });
|
|
179
|
+
if (!jwksRes.ok) {
|
|
180
|
+
console.error(` ❌ JWKS fetch failed: HTTP ${jwksRes.status}`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
const jwks = await jwksRes.json();
|
|
184
|
+
const keyCount = jwks.keys?.length ?? 0;
|
|
185
|
+
console.log(` ✅ JWKS reachable — ${keyCount} key(s) found`);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
189
|
+
console.error(` ❌ JWKS fetch error: ${message}`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|