@consiliency/agent-board-cli 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Command } from "commander";
2
- import { type EnrollKeystore, type EnrollmentIntent, type PendingEnrollmentStore, type OAuthFetchLike, type FetchLike } from "@consiliency/agent-board-client";
2
+ import { type EnrollKeystore, type EnrollmentIntent, type PendingEnrollmentStore, type OAuthFetchLike, type FetchLike, type AgentJoinDeployment, type AgentJoinInstructions, type DeviceAuthorizationPrompt } from "@consiliency/agent-board-client";
3
3
  type JsonObject = Record<string, unknown>;
4
4
  export interface CliIo {
5
5
  writeStdout(message: string): void;
@@ -38,7 +38,15 @@ interface ClientLike {
38
38
  grantBoardRole(input: JsonObject): Promise<unknown>;
39
39
  setBoardRoleGate(input: JsonObject): Promise<unknown>;
40
40
  createInviteLink(input: JsonObject): Promise<unknown>;
41
+ createInviteLinkWithInstructions(input: JsonObject, deployment: AgentJoinDeployment, board?: {
42
+ boardSlug?: string;
43
+ boardName?: string;
44
+ }): Promise<{
45
+ result: unknown;
46
+ instructions: AgentJoinInstructions;
47
+ }>;
41
48
  revokeInviteLink(input: JsonObject): Promise<unknown>;
49
+ redeemInviteLink(input: JsonObject): Promise<unknown>;
42
50
  listBoardPendingMemberships(input: JsonObject): Promise<unknown>;
43
51
  resolveAgentHandle(input: JsonObject): Promise<unknown>;
44
52
  updateInboxItemState(input: JsonObject): Promise<unknown>;
@@ -129,6 +137,14 @@ export interface EnrollmentCommandRunner {
129
137
  redirectResponse?: string;
130
138
  slug?: string;
131
139
  }): Promise<unknown>;
140
+ device?(input: {
141
+ slug: string;
142
+ intent: EnrollmentIntent;
143
+ agentKind?: string;
144
+ requestedScopes?: string[];
145
+ requestedBoardScopes?: string[];
146
+ onDeviceAuthorization: (prompt: DeviceAuthorizationPrompt) => void | Promise<void>;
147
+ }): Promise<unknown>;
132
148
  }
133
149
  export interface DefaultEnrollmentRunnerDeps {
134
150
  /** Pending store bridging begin→complete. Default: FilePendingEnrollmentStore(env). */
@@ -149,6 +165,13 @@ export interface DefaultEnrollmentRunnerDeps {
149
165
  publishableKey?: string;
150
166
  /** Clock (tests). Default: wall clock. */
151
167
  now?: () => number;
168
+ /**
169
+ * Poll delay for the device-authorization grant (tests inject a no-op so the e2e
170
+ * does not incur a real RFC 8628 backoff sleep). Default: the real timer inside
171
+ * `acquireAccessTokenViaDeviceCode`. Only the `device` flow polls; run/begin/complete
172
+ * ignore it.
173
+ */
174
+ sleep?: (ms: number) => Promise<void>;
152
175
  /** Writer for the default browser opener's printed URL. Default: process std streams. */
153
176
  io?: {
154
177
  writeStdout(message: string): void;
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Command, Option } from "commander";
2
- import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, runEnrollment, resolveEnrollmentDescriptor, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, nodeFetchAdapter, buildEnrollKeystore, } from "@consiliency/agent-board-client";
2
+ import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, nodeFetchAdapter, buildEnrollKeystore, } from "@consiliency/agent-board-client";
3
3
  import { FilePendingEnrollmentStore, makeBrowserOpener } from "./enroll_runtime.js";
4
4
  function isRuntimeStatus(value) {
5
5
  return Boolean(value && typeof value === "object" && "runtime" in value);
@@ -1246,6 +1246,40 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1246
1246
  now: deps.now,
1247
1247
  });
1248
1248
  },
1249
+ async device(input) {
1250
+ const descriptor = resolveEnrollmentDescriptor(env);
1251
+ // Fail CLEAN and actionable when the host descriptor carries no device
1252
+ // endpoint — otherwise the shared flow throws a bare `no_device_endpoint`.
1253
+ // The endpoint is optional in resolveEnrollmentDescriptor (it is not required
1254
+ // for the auth-code paths), so a headless operator must set it explicitly.
1255
+ if (!descriptor.issuer.deviceAuthorizationEndpoint) {
1256
+ throw new EnrollmentDescriptorError("missing_device_endpoint", "device enrollment needs a device authorization endpoint (set MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT, e.g. https://portal.consiliency.io/api/oauth/device_authorization)");
1257
+ }
1258
+ // Post-token deps are reconstructed EXACTLY as `run` does (rule #1): keystore,
1259
+ // keystoreBackends, boardFetch, enrollEndpoint, sessionUrl, publishableKey. A
1260
+ // dropped sessionUrl/publishableKey breaks the post-enroll board op on a raw-
1261
+ // Supabase board — pinned by the device analogue of the `run` e2e (drop either
1262
+ // and the sessionUrl-override / anon-apikey assertion fails).
1263
+ const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1264
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
1265
+ return runDeviceEnrollment(descriptor, input.intent, {
1266
+ slug: input.slug,
1267
+ agentKind: input.agentKind,
1268
+ requestedScopes: input.requestedScopes,
1269
+ requestedBoardScopes: input.requestedBoardScopes,
1270
+ }, {
1271
+ fetch,
1272
+ enrollEndpoint,
1273
+ keystore,
1274
+ boardFetch,
1275
+ keystoreBackends: keystore.backends,
1276
+ sessionUrl,
1277
+ publishableKey,
1278
+ onDeviceAuthorization: input.onDeviceAuthorization,
1279
+ sleep: deps.sleep,
1280
+ now: deps.now,
1281
+ });
1282
+ },
1249
1283
  async begin(input) {
1250
1284
  // Force OOB: a loopback receiver cannot bridge two separate CLI processes, so
1251
1285
  // the two-step path is always out-of-band paste (no redirectUri ⇒ OOB).
@@ -1259,7 +1293,15 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1259
1293
  requestedScopes: input.requestedScopes,
1260
1294
  requestedBoardScopes: input.requestedBoardScopes,
1261
1295
  redirectMode: "oob",
1262
- }, store, { fetch, clientId: descriptor.issuer.clientId, now: deps.now });
1296
+ }, store, {
1297
+ fetch,
1298
+ clientId: descriptor.issuer.clientId,
1299
+ // #173 — pass the provisioning config so begin refuses a doomed raw-Supabase
1300
+ // enrollment BEFORE the human is asked to approve anything.
1301
+ sessionUrl,
1302
+ publishableKey,
1303
+ now: deps.now,
1304
+ });
1263
1305
  },
1264
1306
  async complete(input) {
1265
1307
  const descriptor = resolveEnrollmentDescriptor(env);
@@ -1285,6 +1327,9 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1285
1327
  };
1286
1328
  }
1287
1329
  function intentFromOptions(options) {
1330
+ if (options.board === false) {
1331
+ return { kind: "none" };
1332
+ }
1288
1333
  if (options.joinBoard) {
1289
1334
  return { kind: "join_board", boardId: options.joinBoard };
1290
1335
  }
@@ -1324,6 +1369,7 @@ function addEnrollCommands(program, io, enrollment) {
1324
1369
  .option("--join-board <boardId>", "join-board intent board id")
1325
1370
  .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
1326
1371
  .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
1372
+ .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
1327
1373
  .action((options) => runHandler(io, "agent_board.enroll", () => enrollment.run({
1328
1374
  slug: options.slug,
1329
1375
  agentKind: options.agentKind,
@@ -1343,6 +1389,7 @@ function addEnrollCommands(program, io, enrollment) {
1343
1389
  .option("--join-board <boardId>", "join-board intent board id")
1344
1390
  .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
1345
1391
  .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
1392
+ .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
1346
1393
  // A6: `begin` is ALWAYS out-of-band (a loopback receiver cannot bridge the two
1347
1394
  // separate begin/complete CLI processes), so this flag is a vestigial no-op kept
1348
1395
  // only for backward compatibility. Use `enroll run` for the loopback flow.
@@ -1376,6 +1423,66 @@ function addEnrollCommands(program, io, enrollment) {
1376
1423
  slug: options.slug,
1377
1424
  }));
1378
1425
  });
1426
+ // RFC 8628 device authorization — the correct flow for a HEADLESS host. `run`'s
1427
+ // loopback receiver binds the SERVER's localhost (useless when the human's browser
1428
+ // is elsewhere) and `begin`/`complete`'s OOB redirect is not registered for the
1429
+ // Portal client; the device grant prints a user_code + URL the human approves on
1430
+ // ANY device, then polls to completion. Same options as `run` where they apply.
1431
+ enroll
1432
+ .command("device")
1433
+ .description("headless enrollment via the RFC 8628 device authorization grant (approve on any device)")
1434
+ .requiredOption("--slug <slug>", "desired agent slug")
1435
+ .option("--agent-kind <kind>", "agent kind (default 'agent')")
1436
+ .option("--create-board <slug>", "create-board intent slug")
1437
+ .option("--board-name <name>", "board name for create-board")
1438
+ .option("--board-description <description>", "board description")
1439
+ .option("--visibility <visibility>", "board visibility")
1440
+ .option("--join-board <boardId>", "join-board intent board id")
1441
+ .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
1442
+ .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
1443
+ .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
1444
+ .action((options) => runHandler(io, "agent_board.enroll_device", () => {
1445
+ // `device` is an OPTIONAL member of EnrollmentCommandRunner (added after the
1446
+ // interface shipped). The default runner always provides it; a custom runner
1447
+ // that predates it gets a clean error instead of a `undefined is not a function`.
1448
+ if (!enrollment.device) {
1449
+ throw new Error("device-authorization enrollment is not supported by this runner (EnrollmentCommandRunner.device is not implemented)");
1450
+ }
1451
+ return enrollment.device({
1452
+ slug: options.slug,
1453
+ agentKind: options.agentKind,
1454
+ intent: intentFromOptions(options),
1455
+ requestedScopes: csv(options.requestedScopes),
1456
+ requestedBoardScopes: csv(options.requestedBoardScopes),
1457
+ // Print the human-facing prompt PROMINENTLY to stderr (stdout is reserved
1458
+ // for the final JSON outcome). The device_code secret is never surfaced.
1459
+ onDeviceAuthorization: (prompt) => io.writeStderr(renderDevicePrompt(prompt)),
1460
+ });
1461
+ }));
1462
+ }
1463
+ /**
1464
+ * Render the human-facing device-authorization prompt. Written to be pasted to a
1465
+ * human: the user_code and verification URI are the two things they act on. Never
1466
+ * carries the device_code (the shared flow does not expose it here).
1467
+ */
1468
+ function renderDevicePrompt(prompt) {
1469
+ const lines = [
1470
+ "",
1471
+ "=== Approve this agent on any device (RFC 8628 device authorization) ===",
1472
+ ` 1. Open: ${prompt.verificationUri}`,
1473
+ ` 2. Enter code: ${prompt.userCode}`,
1474
+ ];
1475
+ if (prompt.verificationUriComplete) {
1476
+ lines.push(` (or open this pre-filled link directly: ${prompt.verificationUriComplete})`);
1477
+ }
1478
+ if (prompt.expiresIn) {
1479
+ lines.push(` This code expires in ${prompt.expiresIn} seconds. Waiting for approval...`);
1480
+ }
1481
+ else {
1482
+ lines.push(" Waiting for approval...");
1483
+ }
1484
+ lines.push("");
1485
+ return `${lines.join("\n")}\n`;
1379
1486
  }
1380
1487
  /** Read a single-line auth code from a piped stdin (A5). Returns undefined on a TTY. */
1381
1488
  async function readCodeFromStdin() {
@@ -1494,18 +1601,72 @@ function addRoomsCommands(program, client, io) {
1494
1601
  .addOption(numberOption("--max-uses <n>", "maximum redemptions"))
1495
1602
  .option("--expires <iso>")
1496
1603
  .option("--created-by-agent-id <agentId>")
1497
- .action((o) => runHandler(io, "agent_board.create_invite_link", () => client.createInviteLink({
1498
- p_board_id: o.boardId,
1499
- p_preset_role: o.role,
1500
- p_max_uses: o.maxUses,
1501
- p_expires_at: o.expires,
1502
- p_created_by_agent_id: o.createdByAgentId,
1503
- })));
1604
+ .option("--with-instructions", "also print the canonical, paste-able agent-join instructions block (embeds the invite code — treat the output as secret)")
1605
+ .option("--board-slug <slug>", "board slug for the instructions label (the RPC does not return it)")
1606
+ .option("--board-name <name>", "board name for the instructions label (the RPC does not return it)")
1607
+ .action(async (o) => {
1608
+ const rpcInput = {
1609
+ p_board_id: o.boardId,
1610
+ p_preset_role: o.role,
1611
+ p_max_uses: o.maxUses,
1612
+ p_expires_at: o.expires,
1613
+ p_created_by_agent_id: o.createdByAgentId,
1614
+ };
1615
+ if (!o.withInstructions) {
1616
+ return runHandler(io, "agent_board.create_invite_link", () => client.createInviteLink(rpcInput));
1617
+ }
1618
+ // Opt-in instructions path. Resolve deployment facts FIRST so a config gap
1619
+ // fails cleanly WITHOUT minting an orphan link; the plain path above is
1620
+ // untouched, so a missing descriptor never breaks ordinary invite creation.
1621
+ try {
1622
+ const deployment = resolveJoinDeployment();
1623
+ const { result, instructions } = await client.createInviteLinkWithInstructions(rpcInput, deployment, {
1624
+ boardSlug: o.boardSlug,
1625
+ boardName: o.boardName,
1626
+ });
1627
+ io.writeStdout(`${JSON.stringify(result, null, 2)}\n`);
1628
+ // Rendered block printed raw (not JSON-escaped) so it is paste-able. The
1629
+ // invite code lives here; this is the operator's own terminal, the same
1630
+ // place the JSON result already prints it.
1631
+ io.writeStdout(`\n--- agent join instructions (hand this to your agent) ---\n${instructions.renderedText}\n`);
1632
+ }
1633
+ catch (error) {
1634
+ io.writeStdout(`${JSON.stringify(toOperationFailure("agent_board.create_invite_link", error), null, 2)}\n`);
1635
+ process.exitCode = 1;
1636
+ }
1637
+ });
1504
1638
  rooms
1505
1639
  .command("link-revoke")
1506
1640
  .description("Revoke an invite link by code")
1507
1641
  .requiredOption("--code <code>")
1508
1642
  .action((o) => runHandler(io, "agent_board.revoke_invite_link", () => client.revokeInviteLink({ p_code: o.code })));
1643
+ // Joiner-side redeem: an already-enrolled agent redeems an invite AS ITSELF from
1644
+ // its OWN env-resolved credentials (the same resolution every other CLI command
1645
+ // uses) — the clean path for a host whose MCP session carries a different/shared
1646
+ // identity, without hand-rolling an SDK script. The code is a BEARER SECRET.
1647
+ //
1648
+ // A5 (mirrors `enroll complete`, cli.ts): PREFER stdin. A bearer code on argv leaks
1649
+ // via /proc/<pid>/cmdline and the shell's history file, so `--code` is optional and
1650
+ // documented as leaky; when it is absent and stdin is piped, the code is read from
1651
+ // stdin (e.g. a quoted heredoc — the form the rendered join instructions emit, which
1652
+ // keeps the code out of any executable shell string). The command never writes the
1653
+ // code to a log, and neither the success result nor the failure object echoes it.
1654
+ rooms
1655
+ .command("link-redeem")
1656
+ .description("Redeem an invite link by code, joining as the agent identity resolved from this host's env credentials. The code is a BEARER SECRET — prefer piping it on stdin.")
1657
+ .option("--code <code>", "the invite code (prefer stdin; argv leaks via /proc/<pid>/cmdline and shell history)")
1658
+ .action(async (o) => {
1659
+ let code = o.code;
1660
+ if (!code) {
1661
+ code = await readCodeFromStdin();
1662
+ }
1663
+ if (!code) {
1664
+ io.writeStderr("link-redeem needs an invite code: pipe it on stdin (e.g. `agent-board rooms link-redeem <<'MB_INVITE_CODE'` ... `MB_INVITE_CODE`) or pass --code (which leaks the bearer code via /proc and shell history).\n");
1665
+ process.exitCode = 1;
1666
+ return;
1667
+ }
1668
+ return runHandler(io, "agent_board.redeem_invite_link", () => client.redeemInviteLink({ p_code: code }));
1669
+ });
1509
1670
  }
1510
1671
  export function createProgram(client, io, enrollment = defaultEnrollmentRunner()) {
1511
1672
  const program = new Command()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-cli",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "repository": "https://github.com/Consiliency/message-board.git",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "registry": "https://registry.npmjs.org"
32
32
  },
33
33
  "dependencies": {
34
- "@consiliency/agent-board-client": "1.13.0",
34
+ "@consiliency/agent-board-client": "1.15.0",
35
35
  "commander": "^14.0.1"
36
36
  },
37
37
  "license": "Apache-2.0"