@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,453 @@
|
|
|
1
|
+
import nacl from "tweetnacl";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as render from "../render.js";
|
|
5
|
+
import { readAdminPassFileSecure, defaultKeysDir, resolveLocalAdminPass, resolveAdminUser, resolveKeyPath, authFetch, } from "../lib/auth-resolve.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
|
+
const api = (method, path, body, options) => cli.api(method, path, body, options);
|
|
12
|
+
const b64url = (bytes) => cli.b64url(bytes);
|
|
13
|
+
const privKeyPath = (agentId, keysDir) => cli.privKeyPath(agentId, keysDir);
|
|
14
|
+
const pubKeyPath = (agentId, keysDir) => cli.pubKeyPath(agentId, keysDir);
|
|
15
|
+
const shouldShowInlineSecretWarning = (optValue, fromEnv, secretFlagNames, flagName) => cli.shouldShowInlineSecretWarning(optValue, fromEnv, secretFlagNames, flagName);
|
|
16
|
+
const resolveHttpPort = (opts, mode) => cli.resolveHttpPort(opts, mode);
|
|
17
|
+
const resolveOpsPort = (opts) => cli.resolveOpsPort(opts);
|
|
18
|
+
const resolveEffectiveOpsUrl = (opts) => cli.resolveEffectiveOpsUrl(opts);
|
|
19
|
+
const seedAgentViaOpsApi = (opsPortOrUrl, agentId, pubKeyB64url, adminUser, adminPass) => cli.seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, adminUser, adminPass);
|
|
20
|
+
const agentRecordIsAdmin = (record) => cli.agentRecordIsAdmin(record);
|
|
21
|
+
/** Register the `flair agent` command group (flair#1630). */
|
|
22
|
+
export function register(program) {
|
|
23
|
+
// ─── flair agent ─────────────────────────────────────────────────────────────
|
|
24
|
+
const agent = program.command("agent").description("Manage Flair agents");
|
|
25
|
+
agent
|
|
26
|
+
.command("add <id>")
|
|
27
|
+
.description("Register a new agent in a running Flair instance")
|
|
28
|
+
.option("--name <name>", "Display name (defaults to id)")
|
|
29
|
+
.option("--port <port>", "Harper HTTP port")
|
|
30
|
+
.option("--admin-pass <pass>", "Admin password for registration")
|
|
31
|
+
.option("--admin-pass-file <path>", "Read the admin password from a file (chmod 600 enforced). Preferred over inline --admin-pass — keeps the secret out of ps and shell history; works for remote targets too (an explicit flag is operator intent).")
|
|
32
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
33
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
34
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
35
|
+
.option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
|
|
36
|
+
.option("--ops-target <url>", "Explicit ops API URL to seed the Agent on (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
37
|
+
.action(async (id, opts) => {
|
|
38
|
+
const httpPort = resolveHttpPort(opts);
|
|
39
|
+
const opsPort = resolveOpsPort(opts);
|
|
40
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
41
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
42
|
+
const name = opts.name ?? id;
|
|
43
|
+
// Where to seed the Agent record. Default is localhost (opsPort). When
|
|
44
|
+
// --ops-target or --target is given, seed on the remote instead of localhost
|
|
45
|
+
// (#514 — agent add could only ever hit localhost ops). Precedence matches
|
|
46
|
+
// `flair import`: explicit --ops-target > derive from --target > localhost.
|
|
47
|
+
const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
|
|
48
|
+
const isRemoteTarget = typeof seedOpsTarget === "string";
|
|
49
|
+
// flair#1259 — --admin-pass-file resolves into the same explicit slot the
|
|
50
|
+
// inline flag uses (same shape as `flair federation sync`), read in-process
|
|
51
|
+
// via readAdminPassFileSecure so the secret never appears in ps or shell
|
|
52
|
+
// history. This does NOT weaken the #1085 remote guard below: an explicit
|
|
53
|
+
// flag naming a file IS operator intent toward this target, exactly like an
|
|
54
|
+
// explicit inline --admin-pass — what the guard blocks is the AMBIENT
|
|
55
|
+
// env/local-file fallbacks silently traveling to a third-party host.
|
|
56
|
+
if (!opts.adminPass && opts.adminPassFile) {
|
|
57
|
+
try {
|
|
58
|
+
opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
|
|
66
|
+
// ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
|
|
67
|
+
// applied for a remote target — see resolveLocalAdminPass.
|
|
68
|
+
let adminPass;
|
|
69
|
+
try {
|
|
70
|
+
adminPass = resolveLocalAdminPass(opts.adminPass, isRemoteTarget);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
console.error(`Error: ${err.message}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
if (!adminPass) {
|
|
77
|
+
if (isRemoteTarget) {
|
|
78
|
+
console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add when targeting " +
|
|
79
|
+
"a remote instance (--target/--ops-target) — the local ~/.flair/admin-pass and FLAIR_ADMIN_PASS " +
|
|
80
|
+
"fallbacks are never used for remote targets. Prefer --admin-pass-file: it keeps the secret out of ps.");
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add (needed to insert " +
|
|
84
|
+
"into Agent table). Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
|
|
85
|
+
}
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
mkdirSync(keysDir, { recursive: true });
|
|
89
|
+
const privPath = privKeyPath(id, keysDir);
|
|
90
|
+
const pubPath = pubKeyPath(id, keysDir);
|
|
91
|
+
let pubKeyB64url;
|
|
92
|
+
if (existsSync(privPath)) {
|
|
93
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
94
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
95
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
96
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const kp = nacl.sign.keyPair();
|
|
100
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
101
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
102
|
+
chmodSync(privPath, 0o600);
|
|
103
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
104
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
105
|
+
console.log(`Keypair written: ${privPath}`);
|
|
106
|
+
}
|
|
107
|
+
await seedAgentViaOpsApi(seedOpsTarget, id, pubKeyB64url, adminUser, adminPass);
|
|
108
|
+
console.log(typeof seedOpsTarget === "string"
|
|
109
|
+
? `✅ Agent '${id}' (${name}) registered (ops: ${seedOpsTarget})`
|
|
110
|
+
: `✅ Agent '${id}' (${name}) registered`);
|
|
111
|
+
console.log(` Private key: ${privPath}`);
|
|
112
|
+
console.log(` Public key: ${pubKeyB64url}`);
|
|
113
|
+
// flair#1280 — connector legibility at provisioning time: an OAuth /mcp
|
|
114
|
+
// connector resolves its own token subject to an Agent via
|
|
115
|
+
// Credential(kind:idp), NOT via this key, and the two identities are
|
|
116
|
+
// DISTINCT unless linked. One line here saves the "my connector memory is
|
|
117
|
+
// empty" discovery later.
|
|
118
|
+
console.log(` Note: an OAuth /mcp connector maps its own IdP subject to an Agent (distinct from '${id}' by default).\n` +
|
|
119
|
+
` To point a connector at '${id}': flair mcp enable --principal ${id} --idp-subject <your-idp-login>`);
|
|
120
|
+
});
|
|
121
|
+
agent
|
|
122
|
+
.command("list")
|
|
123
|
+
.description("List all agents")
|
|
124
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
125
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
126
|
+
.option("--agent <id>", "Agent ID to authenticate as via Ed25519 (or FLAIR_AGENT_ID env) when no admin pass")
|
|
127
|
+
.option("--keys-dir <dir>", "Directory holding the agent's Ed25519 key")
|
|
128
|
+
.option("--port <port>", "Harper HTTP port")
|
|
129
|
+
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
130
|
+
.action(async (opts) => {
|
|
131
|
+
const port = resolveHttpPort(opts);
|
|
132
|
+
// fromEnv is true ONLY when the resolved value came from env (no inline override).
|
|
133
|
+
const adminPassFromEnv = !opts.adminPass && (!!process.env.FLAIR_ADMIN_PASS || !!process.env.HDB_ADMIN_PASSWORD);
|
|
134
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
135
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
|
|
136
|
+
"to keep secrets out of shell history.");
|
|
137
|
+
}
|
|
138
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
139
|
+
const mode = render.resolveOutputMode(opts);
|
|
140
|
+
let agents;
|
|
141
|
+
if (adminPass) {
|
|
142
|
+
const opsPort = resolveOpsPort(opts);
|
|
143
|
+
const auth = Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64");
|
|
144
|
+
// List every Agent without null-scanning the primary key. A
|
|
145
|
+
// `starts_with ""` on `id` makes Harper search the index for nulls, which
|
|
146
|
+
// the bundled Harper (5.0.21) rejects with "id is not indexed for nulls".
|
|
147
|
+
// Use `createdAt > 1970-01-01` as the total "select all" predicate: every
|
|
148
|
+
// Agent row has a non-null createdAt (schema: createdAt: String!), and its
|
|
149
|
+
// index is built — same pattern as the `flair reembed` Memory scan. (#500)
|
|
150
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
|
153
|
+
body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table: "Agent", operator: "and", conditions: [{ search_attribute: "createdAt", search_type: "greater_than", search_value: "1970-01-01" }], get_attributes: ["id", "name", "createdAt"] }),
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
const text = await res.text().catch(() => "");
|
|
157
|
+
console.error(`${render.icons.error} ${res.status} ${text}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
agents = await res.json();
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// No admin pass → authenticate as the AGENT via Ed25519. The Agent table's
|
|
164
|
+
// allowRead() is allowVerified — a bare unauthenticated GET /Agent returns
|
|
165
|
+
// 403 AccessViolation (the dogfood symptom: the natural "did my agent
|
|
166
|
+
// register?" check errored on a healthy install). A verified agent reads
|
|
167
|
+
// the principal table for discovery, so sign the request with its key.
|
|
168
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
169
|
+
const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
|
|
170
|
+
const keysDir = opts.keysDir ?? process.env.FLAIR_KEY_DIR ?? defaultKeysDir();
|
|
171
|
+
let res;
|
|
172
|
+
if (agentId) {
|
|
173
|
+
const keyPath = resolveKeyPath(agentId) ?? join(keysDir, `${agentId}.key`);
|
|
174
|
+
if (!existsSync(keyPath)) {
|
|
175
|
+
console.error(`${render.icons.error} no key for agent '${agentId}' (looked in ${keysDir}). Pass --admin-pass, --keys-dir, or a registered --agent.`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
res = await authFetch(baseUrl, agentId, keyPath, "GET", "/Agent");
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// No agent identity available either. Try anonymously, but if it 403s
|
|
182
|
+
// (the common case), tell the user exactly how to authenticate rather
|
|
183
|
+
// than dumping a raw AccessViolation.
|
|
184
|
+
res = await fetch(`${baseUrl}/Agent`, { headers: { "Content-Type": "application/json" } });
|
|
185
|
+
}
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
const text = await res.text().catch(() => "");
|
|
188
|
+
if (res.status === 403 && !agentId) {
|
|
189
|
+
console.error(`${render.icons.error} 403 — listing agents requires authentication.`);
|
|
190
|
+
console.error(` Use ${render.wrap(render.c.cyan, "--agent <id>")} (or set FLAIR_AGENT_ID) to authenticate as a registered agent,`);
|
|
191
|
+
console.error(` or ${render.wrap(render.c.cyan, "--admin-pass")} / FLAIR_ADMIN_PASS for the admin view.`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
console.error(`${render.icons.error} ${res.status} ${text}`);
|
|
195
|
+
}
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const data = await res.json();
|
|
199
|
+
agents = Array.isArray(data) ? data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })) : [];
|
|
200
|
+
}
|
|
201
|
+
agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
202
|
+
if (mode === "json") {
|
|
203
|
+
console.log(render.asJSON(agents));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (agents.length === 0) {
|
|
207
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no agents")}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
console.log(`${render.wrap(render.c.bold, String(agents.length))} agents\n`);
|
|
211
|
+
const cols = [
|
|
212
|
+
{ label: "id", key: "id", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
|
|
213
|
+
{ label: "name", key: "name", format: (v) => String(v ?? "—") },
|
|
214
|
+
{ label: "created", key: "createdAt", format: (v) => render.wrap(render.c.dim, v ? String(v).slice(0, 10) : "—") },
|
|
215
|
+
];
|
|
216
|
+
console.log(render.table(cols, agents));
|
|
217
|
+
});
|
|
218
|
+
agent
|
|
219
|
+
.command("show <id>")
|
|
220
|
+
.description("Show agent details")
|
|
221
|
+
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
222
|
+
.action(async (id, opts) => {
|
|
223
|
+
const out = await api("GET", `/Agent/${id}`);
|
|
224
|
+
const mode = render.resolveOutputMode(opts);
|
|
225
|
+
if (mode === "json") {
|
|
226
|
+
console.log(render.asJSON(out));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!out || (typeof out === "object" && !out.id)) {
|
|
230
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no agent ${id}`)}`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
console.log(render.wrap(render.c.bold, String(out.id)));
|
|
234
|
+
if (out.name)
|
|
235
|
+
console.log(render.kv("name", String(out.name)));
|
|
236
|
+
if (out.kind)
|
|
237
|
+
console.log(render.kv("kind", render.wrap(render.c.cyan, String(out.kind))));
|
|
238
|
+
if (out.status) {
|
|
239
|
+
const statusColor = out.status === "active" ? render.c.green : out.status === "disabled" ? render.c.red : render.c.yellow;
|
|
240
|
+
console.log(render.kv("status", render.wrap(statusColor, String(out.status))));
|
|
241
|
+
}
|
|
242
|
+
if (out.defaultTrustTier)
|
|
243
|
+
console.log(render.kv("trust tier", String(out.defaultTrustTier)));
|
|
244
|
+
// flair#941 — read the authority, not the mirror. See `principal show`.
|
|
245
|
+
if (agentRecordIsAdmin(out))
|
|
246
|
+
console.log(render.kv("admin", render.wrap(render.c.magenta, "yes")));
|
|
247
|
+
if (out.runtime)
|
|
248
|
+
console.log(render.kv("runtime", String(out.runtime)));
|
|
249
|
+
if (out.publicKey)
|
|
250
|
+
console.log(render.kv("publicKey", render.wrap(render.c.dim, String(out.publicKey))));
|
|
251
|
+
if (out.createdAt)
|
|
252
|
+
console.log(render.kv("created", `${render.relativeTime(out.createdAt)} ${render.wrap(render.c.dim, `(${out.createdAt})`)}`));
|
|
253
|
+
if (out.updatedAt && out.updatedAt !== out.createdAt) {
|
|
254
|
+
console.log(render.kv("updated", `${render.relativeTime(out.updatedAt)} ${render.wrap(render.c.dim, `(${out.updatedAt})`)}`));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
agent
|
|
258
|
+
.command("rotate-key <id>")
|
|
259
|
+
.description("Rotate an agent's Ed25519 keypair")
|
|
260
|
+
.option("--port <port>", "Harper HTTP port")
|
|
261
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
262
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
263
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
264
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
265
|
+
.action(async (id, opts) => {
|
|
266
|
+
const httpPort = resolveHttpPort(opts);
|
|
267
|
+
const opsPort = resolveOpsPort(opts);
|
|
268
|
+
// fromEnv is true ONLY when the resolved value came from env (no inline override).
|
|
269
|
+
const adminPassFromEnv = !opts.adminPass && !!process.env.FLAIR_ADMIN_PASS;
|
|
270
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
271
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
|
|
272
|
+
"to keep secrets out of shell history.");
|
|
273
|
+
}
|
|
274
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
275
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
276
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
277
|
+
if (!adminPass) {
|
|
278
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for key rotation");
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
mkdirSync(keysDir, { recursive: true });
|
|
282
|
+
const currentPrivPath = privKeyPath(id, keysDir);
|
|
283
|
+
const currentPubPath = pubKeyPath(id, keysDir);
|
|
284
|
+
const backupPrivPath = currentPrivPath + ".bak";
|
|
285
|
+
// Generate new keypair
|
|
286
|
+
console.log(`Generating new keypair for agent '${id}'...`);
|
|
287
|
+
const kp = nacl.sign.keyPair();
|
|
288
|
+
const newSeed = kp.secretKey.slice(0, 32);
|
|
289
|
+
const newPubKeyB64url = b64url(kp.publicKey);
|
|
290
|
+
// Back up old key if it exists
|
|
291
|
+
if (existsSync(currentPrivPath)) {
|
|
292
|
+
writeFileSync(backupPrivPath, readFileSync(currentPrivPath));
|
|
293
|
+
chmodSync(backupPrivPath, 0o600);
|
|
294
|
+
console.log(`Old key backed up to: ${backupPrivPath}`);
|
|
295
|
+
}
|
|
296
|
+
// Update publicKey in Flair via operations API
|
|
297
|
+
console.log(`Updating public key in Flair via operations API...`);
|
|
298
|
+
const opsUrl = `http://127.0.0.1:${opsPort}/`;
|
|
299
|
+
const auth = Buffer.from(`${adminUser}:${adminPass}`).toString("base64");
|
|
300
|
+
const updateBody = {
|
|
301
|
+
operation: "update",
|
|
302
|
+
database: "flair",
|
|
303
|
+
table: "Agent",
|
|
304
|
+
records: [{ id, publicKey: newPubKeyB64url, updatedAt: new Date().toISOString() }],
|
|
305
|
+
};
|
|
306
|
+
const updateRes = await fetch(opsUrl, {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
|
309
|
+
body: JSON.stringify(updateBody),
|
|
310
|
+
signal: AbortSignal.timeout(10_000),
|
|
311
|
+
});
|
|
312
|
+
if (!updateRes.ok) {
|
|
313
|
+
const text = await updateRes.text().catch(() => "");
|
|
314
|
+
// Roll back: keep old key in place (don't write new key yet)
|
|
315
|
+
if (existsSync(backupPrivPath)) {
|
|
316
|
+
// Restore not needed — we haven't written new key yet
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`Failed to update public key in Flair (${updateRes.status}): ${text}`);
|
|
319
|
+
}
|
|
320
|
+
console.log(`Public key updated in Flair ✓`);
|
|
321
|
+
// Write new private key (only after Flair update succeeds)
|
|
322
|
+
writeFileSync(currentPrivPath, Buffer.from(newSeed));
|
|
323
|
+
chmodSync(currentPrivPath, 0o600);
|
|
324
|
+
writeFileSync(currentPubPath, Buffer.from(kp.publicKey));
|
|
325
|
+
console.log(`New private key written: ${currentPrivPath} ✓`);
|
|
326
|
+
// Verify new key works
|
|
327
|
+
console.log(`Verifying new Ed25519 auth...`);
|
|
328
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
329
|
+
const verifyRes = await authFetch(httpUrl, id, currentPrivPath, "GET", `/Agent/${id}`);
|
|
330
|
+
if (!verifyRes.ok) {
|
|
331
|
+
console.error(`⚠️ Auth verification failed (${verifyRes.status}). Old key is backed up at: ${backupPrivPath}`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
console.log(`Ed25519 auth verified ✓`);
|
|
335
|
+
console.log(`\n✅ Key rotation complete for agent '${id}'`);
|
|
336
|
+
console.log(` New public key: ${newPubKeyB64url}`);
|
|
337
|
+
console.log(` Private key: ${currentPrivPath}`);
|
|
338
|
+
console.log(` Old key backup: ${backupPrivPath}`);
|
|
339
|
+
});
|
|
340
|
+
// ─── flair agent remove ──────────────────────────────────────────────────────
|
|
341
|
+
agent
|
|
342
|
+
.command("remove <id>")
|
|
343
|
+
.description("Remove an agent and all its data from Flair")
|
|
344
|
+
.option("--keep-keys", "Do not delete key files from disk")
|
|
345
|
+
.option("--port <port>", "Harper HTTP port")
|
|
346
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
347
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
|
|
348
|
+
.option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
|
|
349
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
350
|
+
.option("--force", "Skip interactive confirmation (required when stdin is not a TTY)")
|
|
351
|
+
.action(async (id, opts) => {
|
|
352
|
+
const opsPort = resolveOpsPort(opts);
|
|
353
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
354
|
+
const adminUser = resolveAdminUser(opts.adminUser);
|
|
355
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
356
|
+
if (!adminPass) {
|
|
357
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for agent remove");
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
361
|
+
async function opsPost(body) {
|
|
362
|
+
return fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
365
|
+
body: JSON.stringify(body),
|
|
366
|
+
signal: AbortSignal.timeout(10_000),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
// Fetch agent info and memory count for confirmation
|
|
370
|
+
const agentRes = await opsPost({ operation: "search_by_value", database: "flair", table: "Agent", search_attribute: "id", search_value: id, get_attributes: ["id", "name"] });
|
|
371
|
+
const agentData = agentRes.ok ? await agentRes.json().catch(() => null) : null;
|
|
372
|
+
const agentName = agentData?.[0]?.name ?? id;
|
|
373
|
+
const memRes = await opsPost({ operation: "search_by_value", database: "flair", table: "Memory", search_attribute: "agentId", search_value: id, get_attributes: ["id"] });
|
|
374
|
+
const memories = memRes.ok ? await memRes.json().catch(() => []) : [];
|
|
375
|
+
const memoryCount = Array.isArray(memories) ? memories.length : 0;
|
|
376
|
+
// Confirmation
|
|
377
|
+
const isInteractive = process.stdin.isTTY;
|
|
378
|
+
if (!opts.force) {
|
|
379
|
+
if (!isInteractive) {
|
|
380
|
+
console.error("Error: stdin is not a TTY. Use --force to skip confirmation.");
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
console.log(`⚠️ About to permanently remove agent '${agentName}' (${id})`);
|
|
384
|
+
console.log(` Memories to delete: ${memoryCount}`);
|
|
385
|
+
process.stdout.write(`\nType 'yes' to confirm: `);
|
|
386
|
+
const answer = await new Promise((resolve) => {
|
|
387
|
+
let buf = "";
|
|
388
|
+
process.stdin.setEncoding("utf-8");
|
|
389
|
+
process.stdin.resume();
|
|
390
|
+
process.stdin.on("data", (chunk) => {
|
|
391
|
+
buf += chunk;
|
|
392
|
+
if (buf.includes("\n")) {
|
|
393
|
+
process.stdin.pause();
|
|
394
|
+
resolve(buf.trim());
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
if (answer !== "yes") {
|
|
399
|
+
console.log("Aborted.");
|
|
400
|
+
process.exit(0);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
console.log(`Removing agent '${agentName}' (${id}) with ${memoryCount} memories...`);
|
|
405
|
+
}
|
|
406
|
+
// Delete all memories
|
|
407
|
+
if (memoryCount > 0) {
|
|
408
|
+
console.log(`Deleting ${memoryCount} memories...`);
|
|
409
|
+
for (const mem of (Array.isArray(memories) ? memories : [])) {
|
|
410
|
+
if (!mem?.id)
|
|
411
|
+
continue;
|
|
412
|
+
await opsPost({ operation: "delete", database: "flair", table: "Memory", ids: [mem.id] }).catch(() => { });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Delete all souls
|
|
416
|
+
const soulRes = await opsPost({ operation: "search_by_value", database: "flair", table: "Soul", search_attribute: "agentId", search_value: id, get_attributes: ["id"] });
|
|
417
|
+
const souls = soulRes.ok ? await soulRes.json().catch(() => []) : [];
|
|
418
|
+
if (Array.isArray(souls) && souls.length > 0) {
|
|
419
|
+
console.log(`Deleting ${souls.length} soul entries...`);
|
|
420
|
+
for (const soul of souls) {
|
|
421
|
+
if (!soul?.id)
|
|
422
|
+
continue;
|
|
423
|
+
await opsPost({ operation: "delete", database: "flair", table: "Soul", ids: [soul.id] }).catch(() => { });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Delete agent record
|
|
427
|
+
const delRes = await opsPost({ operation: "delete", database: "flair", table: "Agent", ids: [id] });
|
|
428
|
+
if (!delRes.ok) {
|
|
429
|
+
const text = await delRes.text().catch(() => "");
|
|
430
|
+
throw new Error(`Failed to delete agent record (${delRes.status}): ${text}`);
|
|
431
|
+
}
|
|
432
|
+
// Delete key files (unless --keep-keys)
|
|
433
|
+
if (!opts.keepKeys) {
|
|
434
|
+
const privPath = privKeyPath(id, keysDir);
|
|
435
|
+
const pubPath = pubKeyPath(id, keysDir);
|
|
436
|
+
const backupPath = privPath + ".bak";
|
|
437
|
+
for (const p of [privPath, pubPath, backupPath]) {
|
|
438
|
+
if (existsSync(p)) {
|
|
439
|
+
try {
|
|
440
|
+
const { unlinkSync: ul } = await import("node:fs");
|
|
441
|
+
ul(p);
|
|
442
|
+
}
|
|
443
|
+
catch { /* best effort */ }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
console.log("Key files deleted.");
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
console.log("Key files preserved (--keep-keys).");
|
|
450
|
+
}
|
|
451
|
+
console.log(`\n✅ Agent '${id}' removed successfully`);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { buildEd25519Auth, resolveKeyPath } from "../lib/auth-resolve.js";
|
|
2
|
+
import * as render from "../render.js";
|
|
3
|
+
let cli;
|
|
4
|
+
/** Bind the cli-locals this module depends on. */
|
|
5
|
+
export function bindCli(fns) {
|
|
6
|
+
cli = fns;
|
|
7
|
+
}
|
|
8
|
+
function resolveBaseUrl(...args) {
|
|
9
|
+
return cli.resolveBaseUrl(...args);
|
|
10
|
+
}
|
|
11
|
+
function resolveSigningAgentId(...args) {
|
|
12
|
+
return cli.resolveSigningAgentId(...args);
|
|
13
|
+
}
|
|
14
|
+
function describeAttentionRow(source, r) {
|
|
15
|
+
const dim = (s) => render.wrap(render.c.dim, s);
|
|
16
|
+
const day = (iso) => (typeof iso === "string" ? iso.slice(0, 10) : "");
|
|
17
|
+
switch (source) {
|
|
18
|
+
case "memory":
|
|
19
|
+
return `${r.content ? String(r.content).replace(/\s+/g, " ").slice(0, 100) : "(no content)"} ${dim(`[${r.agentId} · ${day(r.createdAt)}]`)}`;
|
|
20
|
+
case "relationship":
|
|
21
|
+
return `${r.subject} —${r.predicate}→ ${r.object} ${dim(`[${r.agentId} · ${day(r.createdAt)}]`)}`;
|
|
22
|
+
case "workspaceState":
|
|
23
|
+
return `${r.summary ?? r.ref} ${dim(`[${r.agentId}${r.phase ? ` · ${r.phase}` : ""} · ${String(r.timestamp ?? "").slice(0, 16).replace("T", " ")}]`)}`;
|
|
24
|
+
case "presence":
|
|
25
|
+
return `${r.currentTask} ${dim(`[${r.displayName ?? r.agentId}${r.activity ? ` · ${r.activity}` : ""}]`)}`;
|
|
26
|
+
case "orgEvent":
|
|
27
|
+
return `${r.summary} ${dim(`[${r.authorId} · ${r.kind} · ${day(r.createdAt)}]`)}`;
|
|
28
|
+
default:
|
|
29
|
+
return JSON.stringify(r);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function register(program) {
|
|
33
|
+
// ─── flair attention ─────────────────────────────────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// Entity-scoped attention query (flair#677). "What's touching entity E in the
|
|
36
|
+
// last N days?" — a unified, grouped-by-source view across Memory,
|
|
37
|
+
// Relationship, WorkspaceState, Presence, and OrgEvent (POST /AttentionQuery,
|
|
38
|
+
// resources/AttentionQuery.ts). Read-only; signed the same way `flair search`
|
|
39
|
+
// signs POST /SemanticSearch. Entity must be a vocabulary string (exact
|
|
40
|
+
// type:value match — resources/entity-vocab.ts); the server 400s anything
|
|
41
|
+
// malformed.
|
|
42
|
+
/** Render one attention-result row for its source group — human-readable mode only. */
|
|
43
|
+
program
|
|
44
|
+
.command("attention <entity>")
|
|
45
|
+
.description("What's touching entity E in the last N days? Grouped view across memory/relationship/workspace/presence/orgevent (POST /AttentionQuery)")
|
|
46
|
+
.option("--days <n>", "Window size in days (default 7)")
|
|
47
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
48
|
+
.option("--key <path>", "Ed25519 private key path")
|
|
49
|
+
.option("--port <port>", "Harper HTTP port")
|
|
50
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
51
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
52
|
+
.option("--json", "Output raw JSON")
|
|
53
|
+
.action(async (entity, opts) => {
|
|
54
|
+
try {
|
|
55
|
+
const { agentId } = resolveSigningAgentId(opts, "attention");
|
|
56
|
+
if (!agentId) {
|
|
57
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
const payload = { entity };
|
|
61
|
+
if (opts.days !== undefined) {
|
|
62
|
+
const n = Number.parseInt(opts.days, 10);
|
|
63
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
64
|
+
console.error("error: --days must be a positive integer");
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
payload.days = n;
|
|
68
|
+
}
|
|
69
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
70
|
+
const headers = { "content-type": "application/json" };
|
|
71
|
+
const keyPath = opts.key || resolveKeyPath(agentId);
|
|
72
|
+
if (keyPath) {
|
|
73
|
+
headers["authorization"] = buildEd25519Auth(agentId, "POST", "/AttentionQuery", keyPath);
|
|
74
|
+
}
|
|
75
|
+
const res = await fetch(`${baseUrl}/AttentionQuery`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers,
|
|
78
|
+
body: JSON.stringify(payload),
|
|
79
|
+
});
|
|
80
|
+
const text = await res.text();
|
|
81
|
+
if (!res.ok)
|
|
82
|
+
throw new Error(text || `HTTP ${res.status}`);
|
|
83
|
+
const result = text ? JSON.parse(text) : {};
|
|
84
|
+
const mode = render.resolveOutputMode(opts);
|
|
85
|
+
if (mode === "json") {
|
|
86
|
+
console.log(render.asJSON(result));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const groups = result.groups ?? {};
|
|
90
|
+
const counts = result.counts ?? {};
|
|
91
|
+
console.log(`${render.icons.info} Attention: ${render.wrap(render.c.bold, result.entity ?? entity)} ` +
|
|
92
|
+
`${render.wrap(render.c.dim, `(last ${result.windowDays ?? payload.days ?? 7}d, since ${result.since ?? "?"})`)}`);
|
|
93
|
+
console.log(render.wrap(render.c.dim, `total: ${counts.total ?? 0}`));
|
|
94
|
+
console.log();
|
|
95
|
+
const sections = [
|
|
96
|
+
{ key: "memory", label: "Memory" },
|
|
97
|
+
{ key: "relationship", label: "Relationship" },
|
|
98
|
+
{ key: "workspaceState", label: "Workspace" },
|
|
99
|
+
{ key: "presence", label: "Presence" },
|
|
100
|
+
{ key: "orgEvent", label: "OrgEvent" },
|
|
101
|
+
];
|
|
102
|
+
for (const { key, label } of sections) {
|
|
103
|
+
const rows = Array.isArray(groups[key]) ? groups[key] : [];
|
|
104
|
+
console.log(`${render.wrap(render.c.bold, label)} ${render.wrap(render.c.dim, `(${rows.length})`)}`);
|
|
105
|
+
if (rows.length === 0) {
|
|
106
|
+
console.log(` ${render.wrap(render.c.dim, "—")}`);
|
|
107
|
+
console.log();
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const r of rows) {
|
|
111
|
+
console.log(` ${describeAttentionRow(key, r)}`);
|
|
112
|
+
}
|
|
113
|
+
console.log();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
console.error(`${render.icons.error} Attention query failed: ${err.message}`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|