@tpsdev-ai/flair 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * cli-shim.cts — Node-version PREFLIGHT for the Flair CLI.
5
+ *
6
+ * THIS IS THE BIN ENTRY (`package.json` "bin": { "flair": "dist/cli-shim.cjs" }).
7
+ *
8
+ * Why a separate CommonJS file instead of a guard inside cli.ts:
9
+ * The real CLI (dist/cli.js) is an ES module. In ESM, every top-level
10
+ * `import` is HOISTED and the whole module graph is LINKED + EVALUATED before
11
+ * the first statement in the file body runs. Flair's deps (harper-fabric-
12
+ * embeddings requires Node >=22, @harperfast/harper / commander require >=20)
13
+ * fail to load on an older engine — so a Node-version check placed even at the
14
+ * very top of cli.ts never executes: the import graph crashes first. That is
15
+ * exactly the silent-onboarding-failure bug (a Harper dev got zero output and
16
+ * no ~/.flair on an old Node, fixed only by upgrading).
17
+ *
18
+ * CommonJS evaluates top-to-bottom and `require()`/`import()` are evaluated
19
+ * lazily — so the version check below runs and prints BEFORE anything tries to
20
+ * load the ESM CLI or any modern dependency. Because every Node since v0.x
21
+ * parses and runs CommonJS, this shim is guaranteed to run and print on the
22
+ * oldest Node a developer could plausibly have.
23
+ *
24
+ * The check itself deliberately uses ONLY ancient-safe syntax — `var`, plain
25
+ * functions, string `.split`/`parseInt`, `console.error`, `process.exit`. No
26
+ * top-level await, no optional chaining, no template literals reaching
27
+ * modern-only APIs — so the guard can never become the thing that fails to
28
+ * parse.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ // ── Node-version preflight (must stay parse-safe + runtime-safe on old Node) ──
32
+ var MIN_NODE_MAJOR = 22;
33
+ function flairCurrentNodeMajor() {
34
+ // process.versions.node is e.g. "18.20.4"; take the leading integer.
35
+ var raw = (process && process.versions && process.versions.node) || "0";
36
+ return parseInt(String(raw).split(".")[0], 10) || 0;
37
+ }
38
+ var flairNodeMajor = flairCurrentNodeMajor();
39
+ if (flairNodeMajor < MIN_NODE_MAJOR) {
40
+ // Plain string concatenation — no template literals, no chalk, nothing that
41
+ // could itself trip on an old engine.
42
+ console.error("");
43
+ console.error(" Flair requires Node.js >= " + MIN_NODE_MAJOR + ".");
44
+ console.error(" You are running Node.js " + (process.versions && process.versions.node ? process.versions.node : "(unknown)") + ".");
45
+ console.error("");
46
+ console.error(" Please upgrade Node and try again:");
47
+ console.error(" https://nodejs.org/ (or use nvm / fnm / volta)");
48
+ console.error("");
49
+ process.exit(1);
50
+ }
51
+ // Node is new enough — hand off to the real ESM CLI. Dynamic import() from
52
+ // CommonJS is the supported ESM-from-CJS bridge. We only reach this line on a
53
+ // supported Node, so the import() expression is never evaluated on an engine
54
+ // that can't load the ESM graph.
55
+ import("./cli.js")
56
+ .then(function (mod) {
57
+ if (mod && typeof mod.runCli === "function") {
58
+ return mod.runCli();
59
+ }
60
+ // Older builds auto-ran on import via import.meta.main; nothing to call.
61
+ return undefined;
62
+ })
63
+ .catch(function (err) {
64
+ console.error(err && err.stack ? err.stack : err);
65
+ process.exit(1);
66
+ });
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } fro
11
11
  import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
12
12
  import { keystore } from "./keystore.js";
13
13
  import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
14
+ import { fabricUpgrade } from "./fabric-upgrade.js";
14
15
  import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
15
16
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
16
17
  // src/ into resources/, which don't survive npm packaging (see also
@@ -491,11 +492,29 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
491
492
  // Send Basic auth whenever the caller passed an adminPass. The caller decides
492
493
  // when to omit it (e.g., local target with authorizeLocal=true).
493
494
  const auth = adminPass !== undefined ? Buffer.from(`${adminUser}:${adminPass}`).toString("base64") : undefined;
495
+ // The ops-API insert bypasses the Agent resource layer, so Agent.post()'s
496
+ // 1.0 Principal defaults (kind/status/displayName/admin/defaultTrustTier/type)
497
+ // never run. Without them a remote-seeded agent lands kind=null, status=null
498
+ // and is invisible to roster/presence/Office-Space queries that filter on
499
+ // status='active' or kind='agent' (#521). Mirror Agent.post() exactly here.
500
+ const now = new Date().toISOString();
494
501
  const body = {
495
502
  operation: "insert",
496
503
  database: "flair",
497
504
  table: "Agent",
498
- records: [{ id: agentId, name: agentId, publicKey: pubKeyB64url, createdAt: new Date().toISOString() }],
505
+ records: [{
506
+ id: agentId,
507
+ name: agentId,
508
+ type: "agent",
509
+ kind: "agent",
510
+ status: "active",
511
+ displayName: agentId,
512
+ admin: false,
513
+ defaultTrustTier: "unverified",
514
+ publicKey: pubKeyB64url,
515
+ createdAt: now,
516
+ updatedAt: now,
517
+ }],
499
518
  };
500
519
  const res = await fetch(url, {
501
520
  method: "POST",
@@ -2142,6 +2161,8 @@ agent
2142
2161
  .option("--admin-pass <pass>", "Admin password for registration")
2143
2162
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
2144
2163
  .option("--ops-port <port>", "Harper operations API port")
2164
+ .option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
2165
+ .option("--ops-target <url>", "Explicit ops API URL to seed the Agent on (env: FLAIR_OPS_TARGET; bypasses port derivation)")
2145
2166
  .action(async (id, opts) => {
2146
2167
  const httpPort = resolveHttpPort(opts);
2147
2168
  const opsPort = resolveOpsPort(opts);
@@ -2149,6 +2170,11 @@ agent
2149
2170
  const adminPass = opts.adminPass;
2150
2171
  const adminUser = DEFAULT_ADMIN_USER;
2151
2172
  const name = opts.name ?? id;
2173
+ // Where to seed the Agent record. Default is localhost (opsPort). When
2174
+ // --ops-target or --target is given, seed on the remote instead of localhost
2175
+ // (#514 — agent add could only ever hit localhost ops). Precedence matches
2176
+ // `flair import`: explicit --ops-target > derive from --target > localhost.
2177
+ const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
2152
2178
  if (!adminPass) {
2153
2179
  console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table)");
2154
2180
  process.exit(1);
@@ -2172,8 +2198,10 @@ agent
2172
2198
  pubKeyB64url = b64url(kp.publicKey);
2173
2199
  console.log(`Keypair written: ${privPath}`);
2174
2200
  }
2175
- await seedAgentViaOpsApi(opsPort, id, pubKeyB64url, adminUser, adminPass);
2176
- console.log(`✅ Agent '${id}' (${name}) registered`);
2201
+ await seedAgentViaOpsApi(seedOpsTarget, id, pubKeyB64url, adminUser, adminPass);
2202
+ console.log(typeof seedOpsTarget === "string"
2203
+ ? `✅ Agent '${id}' (${name}) registered (ops: ${seedOpsTarget})`
2204
+ : `✅ Agent '${id}' (${name}) registered`);
2177
2205
  console.log(` Private key: ${privPath}`);
2178
2206
  console.log(` Public key: ${pubKeyB64url}`);
2179
2207
  });
@@ -5766,14 +5794,104 @@ statusCmd
5766
5794
  console.log("\n✅ no warnings");
5767
5795
  }
5768
5796
  });
5797
+ // ─── flair upgrade --target <fabric> ────────────────────────────────────────
5798
+ //
5799
+ // One-command upgrade of a Flair instance DEPLOYED to a Harper Fabric cluster.
5800
+ // Mirrors `flair deploy`'s credential handling (FABRIC_USER/FABRIC_PASSWORD env
5801
+ // fallbacks, password-via-flag warning) and NEVER prints credentials. The
5802
+ // version-resolution + @harperfast/harper pin + reuse of deploy() lives in
5803
+ // src/fabric-upgrade.ts; this wrapper only does CLI plumbing + the confirm.
5804
+ async function runFabricUpgrade(opts) {
5805
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
5806
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
5807
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
5808
+ const fabricUser = opts.fabricUser ?? process.env.FABRIC_USER;
5809
+ const fabricPassword = opts.fabricPassword ?? process.env.FABRIC_PASSWORD;
5810
+ const check = opts.check ?? false;
5811
+ // Creds are not required for --check (read-only registry + best-effort GET),
5812
+ // but ARE required to actually deploy.
5813
+ if (!check && !(fabricUser && fabricPassword)) {
5814
+ console.error(red("flair upgrade --target: credentials required to deploy"));
5815
+ console.error(" pass --fabric-user + --fabric-password (or FABRIC_USER / FABRIC_PASSWORD env)");
5816
+ console.error(" or use --check to preview the plan without credentials");
5817
+ process.exit(1);
5818
+ }
5819
+ // Warn on password-via-flag (leaks to shell history). Env is preferred.
5820
+ if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
5821
+ console.error(dim("warning: --fabric-password leaks to shell history. Prefer FABRIC_PASSWORD env."));
5822
+ }
5823
+ const upgradeOpts = {
5824
+ target: opts.target,
5825
+ project: opts.project,
5826
+ version: opts.version,
5827
+ harperVersion: opts.harperVersion,
5828
+ fabricUser,
5829
+ fabricPassword,
5830
+ check,
5831
+ restart: opts.restart !== false,
5832
+ replicated: opts.replicated !== false,
5833
+ };
5834
+ console.log(`${green("→")} Upgrading Fabric Flair at ${upgradeOpts.target}`);
5835
+ if (check)
5836
+ console.log(dim(" (--check: plan only, no deploy)"));
5837
+ try {
5838
+ // For a real (non-check) run, confirm first unless --yes. Building the plan
5839
+ // up front would double the registry round-trips; the plan prints inside
5840
+ // fabricUpgrade. We confirm BEFORE invoking when interactive and not --yes.
5841
+ if (!check && !opts.yes && process.stdin.isTTY) {
5842
+ const { createInterface } = await import("node:readline");
5843
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5844
+ const answer = await new Promise((res) => rl.question(`Deploy a fresh ${green("@tpsdev-ai/flair")} to ${upgradeOpts.target}? [y/N] `, (a) => { rl.close(); res(a); }));
5845
+ if (!/^y(es)?$/i.test(answer.trim())) {
5846
+ console.log("Aborted.");
5847
+ return;
5848
+ }
5849
+ }
5850
+ const result = await fabricUpgrade(upgradeOpts);
5851
+ if (check) {
5852
+ console.log(`\n${green("✓")} check complete — run without --check to deploy.`);
5853
+ return;
5854
+ }
5855
+ if (result.plan.upToDate && !result.deployed) {
5856
+ console.log(`\n${green("✓")} already up to date.`);
5857
+ return;
5858
+ }
5859
+ console.log(`\n${green("✓")} Fabric upgrade complete.`);
5860
+ }
5861
+ catch (err) {
5862
+ console.error(red(`\n✗ fabric upgrade failed: ${err.message}`));
5863
+ const hint = err.message?.toLowerCase() ?? "";
5864
+ if (hint.includes("401") || hint.includes("unauthoriz")) {
5865
+ console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
5866
+ }
5867
+ process.exit(1);
5868
+ }
5869
+ }
5769
5870
  // ─── flair upgrade ────────────────────────────────────────────────────────────
5770
5871
  program
5771
5872
  .command("upgrade")
5772
- .description("Upgrade Flair and related packages to latest versions")
5773
- .option("--check", "Only check for updates, don't install")
5873
+ .description("Upgrade Flair local packages by default, or a deployed Fabric with --target")
5874
+ .option("--check", "Only check for updates / show the plan, don't install or deploy")
5774
5875
  .option("--restart", "Restart Flair after upgrade")
5775
5876
  .option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
5877
+ // ── Fabric upgrade (--target) ────────────────────────────────────────────
5878
+ // When --target is passed, upgrade the Flair component DEPLOYED to that
5879
+ // Harper Fabric URL instead of the local npm install. Reuses `flair deploy`
5880
+ // under the hood with the @harperfast/harper pin baked in (flair#513).
5881
+ .option("--target <url>", "Upgrade the Flair deployed to this Fabric URL (not the local install)")
5882
+ .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER) — for --target")
5883
+ .option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD) — for --target")
5884
+ .option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
5885
+ .option("--harper-version <semver>", "Pin @harperfast/harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
5886
+ .option("--project <name>", "Fabric component name for --target", "flair")
5887
+ .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
5888
+ .option("--yes", "Skip the confirmation prompt for --target")
5776
5889
  .action(async (opts) => {
5890
+ // ── Fabric-upgrade branch ───────────────────────────────────────────────
5891
+ if (opts.target) {
5892
+ await runFabricUpgrade(opts);
5893
+ return;
5894
+ }
5777
5895
  const { execSync, execFileSync } = await import("node:child_process");
5778
5896
  const checkOnly = opts.check ?? false;
5779
5897
  const showAll = opts.all ?? false;
@@ -7005,6 +7123,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
7005
7123
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
7006
7124
  .option("--subject <text>", "one-line title / entity this memory is about")
7007
7125
  .option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
7126
+ .option("--visibility <value>", "Memory visibility (sets Memory.visibility). Use 'office' to share office-wide with every team agent; omit (default) to keep it private to this agent (flair#509)")
7008
7127
  .action(async (contentArg, opts) => {
7009
7128
  const content = contentArg ?? opts.content;
7010
7129
  if (!content) {
@@ -7021,6 +7140,8 @@ memory.command("add [content]").requiredOption("--agent <id>")
7021
7140
  body.summary = opts.summary;
7022
7141
  if (opts.subject)
7023
7142
  body.subject = opts.subject;
7143
+ if (opts.visibility)
7144
+ body.visibility = String(opts.visibility).trim();
7024
7145
  if (opts.derivedFrom) {
7025
7146
  body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
7026
7147
  }
@@ -8654,11 +8775,20 @@ program
8654
8775
  .option("--port <port>", "Harper HTTP port")
8655
8776
  .option("--ops-port <port>", "Harper operations API port")
8656
8777
  .option("--url <url>", "Flair base URL (overrides --port)")
8778
+ .option("--ops-target <url>", "Explicit ops API URL for the Agent seed (env: FLAIR_OPS_TARGET; bypasses port derivation). Use when --url is remote and the ops port isn't HTTP-1.")
8657
8779
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
8658
8780
  .option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
8659
8781
  .action(async (importPath, opts) => {
8660
8782
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
8661
8783
  const opsPort = resolveOpsPort(opts);
8784
+ // Resolve where the Agent record is seeded. The Agent goes through the ops
8785
+ // API (the REST surface has no Agent POST handler), so a remote --url import
8786
+ // must NOT silently seed on localhost (#514 — split import). Precedence:
8787
+ // 1. --ops-target / FLAIR_OPS_TARGET → use directly
8788
+ // 2. --url given → derive ops URL from it (port-1 convention)
8789
+ // 3. neither → localhost opsPort (preserves local default)
8790
+ // The remote REST base (--url) is mapped to `target` for derivation.
8791
+ const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.url, opsTarget: opts.opsTarget }) ?? opsPort;
8662
8792
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8663
8793
  if (!adminPass) {
8664
8794
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
@@ -8706,9 +8836,11 @@ program
8706
8836
  ? nacl.sign.keyPair.fromSeed(new Uint8Array(decodedSeed)).publicKey
8707
8837
  : nacl.sign.keyPair.fromSeed(new Uint8Array(decodedSeed.subarray(0, 32))).publicKey;
8708
8838
  const pubKeyB64url = b64url(pubKey);
8709
- // Register agent via ops API
8710
- await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, DEFAULT_ADMIN_USER, adminPass);
8711
- console.log(` Agent registered`);
8839
+ // Register agent via ops API (remote when --url/--ops-target points off-box)
8840
+ await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, DEFAULT_ADMIN_USER, adminPass);
8841
+ console.log(typeof seedOpsTarget === "string"
8842
+ ? ` Agent registered (ops: ${seedOpsTarget})`
8843
+ : ` Agent registered`);
8712
8844
  // Restore memories
8713
8845
  const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
8714
8846
  let memCount = 0;
@@ -9102,9 +9234,159 @@ presence
9102
9234
  console.log(` Status: ${data.presenceStatus}`);
9103
9235
  }
9104
9236
  });
9105
- // Run CLI only when this is the entry point (not when imported for testing)
9106
- if (import.meta.main) {
9237
+ // ─── flair workspace ─────────────────────────────────────────────────────────
9238
+ //
9239
+ // Coordination write surface (ops-wmgx / Kris #510). `workspace set` writes the
9240
+ // agent's OWN WorkspaceState via a signed POST /WorkspaceState. Identity comes
9241
+ // from the Ed25519 signature — the body carries NO agentId, so an agent can only
9242
+ // write as itself (the WorkspaceState.post() handler overwrites agentId from the
9243
+ // signature regardless of body content). Mirrors `presence set`'s signed-POST shape.
9244
+ const MAX_WORKSPACE_FIELD_LENGTH = 2000;
9245
+ const workspace = program.command("workspace").description("Manage agent workspace state (The Office Space)");
9246
+ workspace
9247
+ .command("set")
9248
+ .description("Set your agent's current workspace state (POST /WorkspaceState)")
9249
+ .requiredOption("--ref <ref>", "Workspace ref (branch, worktree, or task ref)")
9250
+ .option("--label <text>", "Human-readable label for this workspace")
9251
+ .option("--provider <name>", "Provider/runtime (e.g. claude-code, openclaw)", "cli")
9252
+ .option("--task <id>", "Task/issue id this workspace is attached to")
9253
+ .option("--phase <phase>", "Current phase (e.g. design, implement, review)")
9254
+ .option("--summary <text>", "Short summary of current workspace state")
9255
+ .option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
9256
+ .option("--port <port>", "Harper HTTP port")
9257
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
9258
+ .action(async (opts) => {
9259
+ const agentId = resolveAgentIdOrEnv(opts);
9260
+ if (!agentId) {
9261
+ console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
9262
+ process.exit(1);
9263
+ }
9264
+ // Validate field lengths (free text → cap to bound the write).
9265
+ for (const [name, val] of [["ref", opts.ref], ["label", opts.label], ["summary", opts.summary]]) {
9266
+ if (val && String(val).length > MAX_WORKSPACE_FIELD_LENGTH) {
9267
+ console.error(`Error: --${name} exceeds ${MAX_WORKSPACE_FIELD_LENGTH} character limit (got ${String(val).length}).`);
9268
+ process.exit(1);
9269
+ }
9270
+ }
9271
+ const keyPath = resolveKeyPath(agentId);
9272
+ if (!keyPath) {
9273
+ console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
9274
+ process.exit(1);
9275
+ }
9276
+ const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
9277
+ const auth = buildEd25519Auth(agentId, "POST", "/WorkspaceState", keyPath);
9278
+ // NOTE: agentId is intentionally NOT included in the body — the handler
9279
+ // attributes the record from the Ed25519 signature (no forging).
9280
+ const now = new Date().toISOString();
9281
+ const body = {
9282
+ id: `${agentId}:${opts.ref}`,
9283
+ ref: opts.ref,
9284
+ provider: opts.provider ?? "cli",
9285
+ timestamp: now,
9286
+ };
9287
+ if (opts.label)
9288
+ body.label = opts.label;
9289
+ if (opts.task)
9290
+ body.taskId = opts.task;
9291
+ if (opts.phase)
9292
+ body.phase = opts.phase;
9293
+ if (opts.summary)
9294
+ body.summary = opts.summary;
9295
+ const res = await fetch(`${baseUrl}/WorkspaceState`, {
9296
+ method: "POST",
9297
+ headers: { "Content-Type": "application/json", Authorization: auth },
9298
+ body: JSON.stringify(body),
9299
+ });
9300
+ if (!res.ok) {
9301
+ const text = await res.text().catch(() => "");
9302
+ console.error(`Error: POST /WorkspaceState failed (${res.status}): ${text}`);
9303
+ process.exit(1);
9304
+ }
9305
+ console.log(`✓ Workspace state updated for '${agentId}': ref=${opts.ref}${opts.phase ? `, phase=${opts.phase}` : ""}`);
9306
+ });
9307
+ // ─── flair orgevent ──────────────────────────────────────────────────────────
9308
+ //
9309
+ // Coordination write surface (ops-wmgx / Kris #510). `orgevent` publishes an
9310
+ // OrgEvent ATTRIBUTED to the authenticated agent via a signed POST /OrgEvent.
9311
+ // The event's authorId comes from the Ed25519 signature — the body carries NO
9312
+ // authorId, so an agent CANNOT forge another agent's events (the OrgEvent.post()
9313
+ // handler overwrites authorId from the signature). Mirrors `presence set`'s shape.
9314
+ const MAX_ORGEVENT_SUMMARY_LENGTH = 500;
9315
+ const MAX_ORGEVENT_DETAIL_LENGTH = 8000;
9316
+ program
9317
+ .command("orgevent")
9318
+ .description("Publish an org-wide coordination event attributed to your agent (POST /OrgEvent)")
9319
+ .requiredOption("--kind <kind>", "Event kind (e.g. coord.claim, coord.release, status)")
9320
+ .requiredOption("--summary <text>", "Short summary of the event")
9321
+ .option("--detail <text>", "Longer detail payload")
9322
+ .option("--scope <scope>", "Scope of the event (e.g. an agent id, repo, or 'org')")
9323
+ .option("--target <agentId>", "Recipient agent id (repeatable)", (val, acc) => { acc.push(val); return acc; }, [])
9324
+ .option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
9325
+ .option("--port <port>", "Harper HTTP port")
9326
+ .option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
9327
+ .action(async (opts) => {
9328
+ const agentId = resolveAgentIdOrEnv(opts);
9329
+ if (!agentId) {
9330
+ console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
9331
+ process.exit(1);
9332
+ }
9333
+ if (opts.summary && String(opts.summary).length > MAX_ORGEVENT_SUMMARY_LENGTH) {
9334
+ console.error(`Error: --summary exceeds ${MAX_ORGEVENT_SUMMARY_LENGTH} character limit (got ${String(opts.summary).length}).`);
9335
+ process.exit(1);
9336
+ }
9337
+ if (opts.detail && String(opts.detail).length > MAX_ORGEVENT_DETAIL_LENGTH) {
9338
+ console.error(`Error: --detail exceeds ${MAX_ORGEVENT_DETAIL_LENGTH} character limit (got ${String(opts.detail).length}).`);
9339
+ process.exit(1);
9340
+ }
9341
+ const keyPath = resolveKeyPath(agentId);
9342
+ if (!keyPath) {
9343
+ console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
9344
+ process.exit(1);
9345
+ }
9346
+ // orgevent reuses --target for recipients, so the remote-URL override is
9347
+ // --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
9348
+ const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
9349
+ const auth = buildEd25519Auth(agentId, "POST", "/OrgEvent", keyPath);
9350
+ // NOTE: authorId is intentionally NOT included in the body — the handler
9351
+ // attributes the event from the Ed25519 signature (no forging).
9352
+ const body = {
9353
+ kind: opts.kind,
9354
+ summary: opts.summary,
9355
+ };
9356
+ if (opts.detail)
9357
+ body.detail = opts.detail;
9358
+ if (opts.scope)
9359
+ body.scope = opts.scope;
9360
+ if (Array.isArray(opts.target) && opts.target.length > 0)
9361
+ body.targetIds = opts.target;
9362
+ const res = await fetch(`${baseUrl}/OrgEvent`, {
9363
+ method: "POST",
9364
+ headers: { "Content-Type": "application/json", Authorization: auth },
9365
+ body: JSON.stringify(body),
9366
+ });
9367
+ if (!res.ok) {
9368
+ const text = await res.text().catch(() => "");
9369
+ console.error(`Error: POST /OrgEvent failed (${res.status}): ${text}`);
9370
+ process.exit(1);
9371
+ }
9372
+ const data = await res.json().catch(() => null);
9373
+ const targets = Array.isArray(opts.target) && opts.target.length > 0 ? ` → ${opts.target.join(", ")}` : "";
9374
+ console.log(`✓ OrgEvent published as '${agentId}': kind=${opts.kind}${targets}`);
9375
+ if (data?.id)
9376
+ console.log(` id: ${data.id}`);
9377
+ });
9378
+ // Parse argv and run the CLI. Exported so the CommonJS preflight shim
9379
+ // (cli-shim.cts → dist/cli-shim.cjs, the real bin entry) can invoke it after
9380
+ // its Node-version check passes. The shim imports this module, so import.meta.main
9381
+ // is false there — without this explicit entry point the CLI would load but never run.
9382
+ async function runCli() {
9107
9383
  await program.parseAsync();
9108
9384
  }
9385
+ // Run CLI directly when this file is the entry point — covers `node dist/cli.js`,
9386
+ // `bun src/cli.ts`, and the test harness (which spawns src/cli.ts under bun).
9387
+ // The packaged bin goes through cli-shim.cjs → runCli() instead.
9388
+ if (import.meta.main) {
9389
+ await runCli();
9390
+ }
9109
9391
  // ─── Exported for testing ─────────────────────────────────────────────────────
9110
- export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
9392
+ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };