From 7801482840d5b31a427fcf5c162f05a7f03efd13 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 12:58:39 -0600 Subject: [PATCH 1/6] feat(users): add `clerk users list` subcommand List users from the target instance with pagination, query search, repeatable identifier filters, and order-by support. Human mode prints a compact table; agent mode and `--json` return the raw BAPI response. --- .changeset/users-list.md | 5 + packages/cli-core/src/cli-program.test.ts | 29 ++- packages/cli-core/src/cli-program.ts | 106 ++++++++++ .../cli-core/src/commands/users/README.md | 26 +++ packages/cli-core/src/commands/users/index.ts | 2 + .../cli-core/src/commands/users/list.test.ts | 194 ++++++++++++++++++ packages/cli-core/src/commands/users/list.ts | 170 +++++++++++++++ .../test/integration/users-commands.test.ts | 50 ++++- 8 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 .changeset/users-list.md create mode 100644 packages/cli-core/src/commands/users/list.test.ts create mode 100644 packages/cli-core/src/commands/users/list.ts diff --git a/.changeset/users-list.md b/.changeset/users-list.md new file mode 100644 index 00000000..f6a91bbe --- /dev/null +++ b/.changeset/users-list.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk users list` with pagination, query search, repeatable identifier filters (`--email-address`, `--phone-number`, `--username`, `--user-id`, `--external-id`), and `--order-by` covering Clerk's common user ordering fields with optional `+`/`-` prefixes. diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 9dc3e6f3..5b556ff3 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -11,12 +11,37 @@ test("registers users as a top-level command", () => { expect(users).toBeDefined(); }); -test("registers users create as a subcommand", () => { +test("registers users create and list as subcommands", () => { const program = createProgram(); const users = program.commands.find((command) => command.name() === "users")!; const names = users.commands.map((command) => command.name()); - expect(names).toContain("create"); + expect(names).toEqual(expect.arrayContaining(["create", "list"])); +}); + +test("users list exposes common filters and pagination options", () => { + const program = createProgram(); + const users = program.commands.find((command) => command.name() === "users")!; + const list = users.commands.find((command) => command.name() === "list")!; + const optionNames = list.options.map((option) => option.long); + + expect(optionNames).toEqual( + expect.arrayContaining([ + "--json", + "--limit", + "--offset", + "--query", + "--email-address", + "--phone-number", + "--username", + "--user-id", + "--external-id", + "--order-by", + "--secret-key", + "--app", + "--instance", + ]), + ); }); test("users create exposes --json output, curated flags, and -d/--data for inline request bodies", () => { diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 57bc812f..670ba77c 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -47,6 +47,47 @@ import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { update } from "./commands/update/index.ts"; import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; +const USER_LIST_ORDER_BY_FIELDS = [ + "created_at", + "updated_at", + "email_address", + "web3wallet", + "first_name", + "last_name", + "phone_number", + "username", + "last_active_at", + "last_sign_in_at", +] as const; + +const USER_LIST_ORDER_BY_CHOICES = USER_LIST_ORDER_BY_FIELDS.flatMap((field) => [ + field, + `+${field}`, + `-${field}`, +]); + +function collectOptionValues(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +function parseIntegerOption( + value: string, + flag: string, + { min, max }: { min: number; max?: number }, +): number { + if (!/^\d+$/.test(value)) { + throwUsageError(`Invalid ${flag} value "${value}". Must be an integer.`); + } + + const parsed = Number.parseInt(value, 10); + if (parsed < min || (typeof max === "number" && parsed > max)) { + const range = typeof max === "number" ? `${min}-${max}` : `>= ${min}`; + throwUsageError(`Invalid ${flag} value "${value}". Must be ${range}.`); + } + + return parsed; +} + export function createProgram() { const program = new Command() .name("clerk") @@ -267,6 +308,7 @@ Give AI agents better Clerk context: install the Clerk skills .option("--app ", "Application ID to target (works from any directory)") .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") .setExamples([ + { command: "clerk users list", description: "List users" }, { command: "clerk users create --email alice@example.com --first-name Alice --yes", description: "Create a user from curated flags", @@ -278,6 +320,70 @@ Give AI agents better Clerk context: install the Clerk skills ]) .action(usersHandlers.menu); + users + .command("list") + .description("List users") + .option("--json", "Output as JSON") + .option("--limit ", "Maximum users to return (1-500)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 500 }), + ) + .option("--offset ", "Users to skip before returning results (0+)", (value) => + parseIntegerOption(value, "--offset", { min: 0 }), + ) + .option("--query ", "Search across common user fields") + .option( + "--email-address ", + "Filter by email address (repeat or comma-separate)", + collectOptionValues, + [], + ) + .option( + "--phone-number ", + "Filter by phone number (repeat or comma-separate)", + collectOptionValues, + [], + ) + .option( + "--username ", + "Filter by username (repeat or comma-separate)", + collectOptionValues, + [], + ) + .option( + "--user-id ", + "Filter by user ID (repeat or comma-separate)", + collectOptionValues, + [], + ) + .option( + "--external-id ", + "Filter by external ID (repeat or comma-separate)", + collectOptionValues, + [], + ) + .addOption( + createOption( + "--order-by ", + "Order by a supported field, optionally prefixed with + or -", + ).choices(USER_LIST_ORDER_BY_CHOICES), + ) + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .setExamples([ + { command: "clerk users list", description: "List users with the default ordering" }, + { + command: "clerk users list --query alice --limit 20", + description: "Search across common user fields with pagination", + }, + { + command: + "clerk users list --email-address alice@example.com --external-id crm_123 --order-by -last_sign_in_at", + description: "Filter by common identifiers and sort by recent sign-in", + }, + ]) + .action(usersHandlers.list); + users .command("create") .description("Create a user") diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 11ee9d82..a5a4ad03 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -40,6 +40,31 @@ Two complementary mechanisms for JSON input work across the users command family ## Commands +### `clerk users list` + +List users from the target instance. + +```sh +clerk users list +clerk users list --json +clerk users list --query alice --limit 20 --offset 40 +clerk users list --email-address alice@example.com --phone-number +15551234567 +clerk users list --user-id user_123 --external-id crm_123 --order-by -last_sign_in_at +clerk users list --app app_123 --instance prod +``` + +Common list filters: + +- `--limit ` +- `--offset ` +- `--query ` +- `--email-address ` repeat or comma-separate values +- `--phone-number ` repeat or comma-separate values +- `--username ` repeat or comma-separate values +- `--user-id ` repeat or comma-separate values +- `--external-id ` repeat or comma-separate values +- `--order-by ` supports Clerk's common `getUserList()` order fields, with optional `+` or `-` + ### `clerk users create` Create a user from curated flags or a raw BAPI request body via `-d` or `--file`. By default, human mode prints a terse success message; pass `--json` for the response body. @@ -68,6 +93,7 @@ Supported curated flags: | Method | Endpoint | Command(s) | | ------ | ----------- | ---------- | +| `GET` | `/v1/users` | `list` | | `POST` | `/v1/users` | `create` | ## Notes diff --git a/packages/cli-core/src/commands/users/index.ts b/packages/cli-core/src/commands/users/index.ts index 051760e0..0c0fe256 100644 --- a/packages/cli-core/src/commands/users/index.ts +++ b/packages/cli-core/src/commands/users/index.ts @@ -1,4 +1,5 @@ import { create } from "./create.ts"; +import { list } from "./list.ts"; import { usersMenu } from "./menu.ts"; export type { UsersActionTargeting, UsersAction } from "./registry.ts"; @@ -10,5 +11,6 @@ export { export const users = { create, + list, menu: usersMenu, }; diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts new file mode 100644 index 00000000..4c03f269 --- /dev/null +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -0,0 +1,194 @@ +import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { captureLog } from "../../test/lib/stubs.ts"; + +const mockBapiRequest = mock(); +mock.module("../../commands/api/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const mockResolveBapiSecretKey = mock(); +mock.module("../../lib/bapi-command.ts", () => ({ + resolveBapiSecretKey: (...args: unknown[]) => mockResolveBapiSecretKey(...args), +})); + +const mockWithSpinner = mock((_msg: string, fn: () => Promise) => fn()); +mock.module("../../lib/spinner.ts", () => ({ + withSpinner: (...args: Parameters) => mockWithSpinner(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { list } = await import("./list.ts"); + +const mockUsers = [ + { + id: "user_123", + first_name: "Alice", + last_name: "Example", + username: "alice", + primary_email_address_id: "idn_1", + email_addresses: [ + { + id: "idn_1", + email_address: "alice@example.com", + }, + ], + }, + { + id: "user_456", + username: "bob", + phone_numbers: [ + { + id: "phn_1", + phone_number: "+15551234567", + }, + ], + }, +]; + +describe("users list", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + let captured: ReturnType; + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveBapiSecretKey.mockResolvedValue("sk_test_123"); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: { data: mockUsers, totalCount: mockUsers.length }, + rawBody: JSON.stringify({ data: mockUsers, totalCount: mockUsers.length }), + }); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + captured = captureLog(); + }); + + afterEach(() => { + captured.teardown(); + mockBapiRequest.mockReset(); + mockResolveBapiSecretKey.mockReset(); + mockWithSpinner.mockReset(); + mockWithSpinner.mockImplementation((_msg: string, fn: () => Promise) => fn()); + mockIsAgent.mockReset(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function runList(options: Parameters[0] = {}) { + return captured.run(() => list(options)); + } + + test("forwards targeting options when resolving the secret key", async () => { + await runList({ json: true, secretKey: "sk_test_override", app: "app_123", instance: "prod" }); + + expect(mockResolveBapiSecretKey).toHaveBeenCalledWith({ + secretKey: "sk_test_override", + app: "app_123", + instance: "prod", + }); + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "GET", + path: "/users", + secretKey: "sk_test_123", + }); + }); + + test("wraps the network read in the standard users list spinner", async () => { + await runList(); + + expect(mockWithSpinner).toHaveBeenCalledWith("Fetching users...", expect.any(Function)); + }); + + test("serializes common filters and pagination into query params", async () => { + await runList({ + query: "alice", + emailAddress: ["alice@example.com", "admin@example.com"], + phoneNumber: ["+15551234567"], + username: ["alice-user"], + userId: ["user_123", "user_456"], + externalId: ["ext_123"], + orderBy: "-last_sign_in_at", + limit: 25, + offset: 50, + }); + + const request = mockBapiRequest.mock.calls[0]?.[0] as { path: string } | undefined; + expect(request).toBeDefined(); + + const url = new URL(request!.path, "https://api.clerk.test"); + expect(url.pathname).toBe("/users"); + expect(url.searchParams.get("query")).toBe("alice"); + expect(url.searchParams.getAll("email_address")).toEqual([ + "alice@example.com", + "admin@example.com", + ]); + expect(url.searchParams.getAll("phone_number")).toEqual(["+15551234567"]); + expect(url.searchParams.getAll("username")).toEqual(["alice-user"]); + expect(url.searchParams.getAll("user_id")).toEqual(["user_123", "user_456"]); + expect(url.searchParams.getAll("external_id")).toEqual(["ext_123"]); + expect(url.searchParams.get("order_by")).toBe("-last_sign_in_at"); + expect(url.searchParams.get("limit")).toBe("25"); + expect(url.searchParams.get("offset")).toBe("50"); + }); + + test("reads users from Clerk's paginated response shape", async () => { + await runList(); + + expect(captured.out).toContain("Alice Example"); + expect(captured.out).toContain("bob"); + }); + + test("prints a concise human-readable table by default", async () => { + await runList(); + + expect(captured.out).toContain("Alice Example"); + expect(captured.out).toContain("alice@example.com"); + expect(captured.out).toContain("user_123"); + expect(captured.out).toContain("bob"); + expect(captured.out).toContain("+15551234567"); + expect(captured.err).toContain("2 users"); + }); + + test("prints a helpful message when no users are returned", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: { data: [], totalCount: 0 }, + rawBody: JSON.stringify({ data: [], totalCount: 0 }), + }); + + await runList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("No users found"); + }); + + test("outputs JSON when requested", async () => { + await runList({ json: true }); + + expect(JSON.parse(captured.out)).toEqual({ + data: mockUsers, + totalCount: mockUsers.length, + }); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await runList(); + + expect(JSON.parse(captured.out)).toEqual({ + data: mockUsers, + totalCount: mockUsers.length, + }); + }); +}); diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts new file mode 100644 index 00000000..c53eb544 --- /dev/null +++ b/packages/cli-core/src/commands/users/list.ts @@ -0,0 +1,170 @@ +import { resolveBapiSecretKey } from "../../lib/bapi-command.ts"; +import { dim, cyan } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { bapiRequest } from "../api/bapi.ts"; + +type UsersListOptions = { + json?: boolean; + secretKey?: string; + app?: string; + instance?: string; + limit?: number; + offset?: number; + query?: string; + emailAddress?: string[]; + phoneNumber?: string[]; + username?: string[]; + userId?: string[]; + externalId?: string[]; + orderBy?: string; +}; + +type UserIdentifier = { id?: string; email_address?: string; phone_number?: string }; + +type BapiUser = { + id: string; + first_name?: string | null; + last_name?: string | null; + username?: string | null; + primary_email_address_id?: string | null; + primary_phone_number_id?: string | null; + email_addresses?: UserIdentifier[]; + phone_numbers?: UserIdentifier[]; +}; + +type PaginatedUsersResponse = { + data?: unknown; + totalCount?: number; +}; + +const COLUMN_PADDING = 2; + +function printJson(data: unknown, options: UsersListOptions = {}): boolean { + if (!options.json && !isAgent()) return false; + log.data(JSON.stringify(data, null, 2)); + return true; +} + +function appendMultiValueParam( + searchParams: URLSearchParams, + key: string, + values?: string[], +): void { + if (!values?.length) return; + + for (const value of values) { + for (const part of value.split(",")) { + const trimmed = part.trim(); + if (trimmed) searchParams.append(key, trimmed); + } + } +} + +function buildUsersListPath(options: UsersListOptions): string { + const searchParams = new URLSearchParams(); + + if (typeof options.limit === "number") { + searchParams.set("limit", String(options.limit)); + } + if (typeof options.offset === "number") { + searchParams.set("offset", String(options.offset)); + } + if (options.query?.trim()) { + searchParams.set("query", options.query.trim()); + } + if (options.orderBy?.trim()) { + searchParams.set("order_by", options.orderBy.trim()); + } + + appendMultiValueParam(searchParams, "email_address", options.emailAddress); + appendMultiValueParam(searchParams, "phone_number", options.phoneNumber); + appendMultiValueParam(searchParams, "username", options.username); + appendMultiValueParam(searchParams, "user_id", options.userId); + appendMultiValueParam(searchParams, "external_id", options.externalId); + + const query = searchParams.toString(); + return query ? `/users?${query}` : "/users"; +} + +function userDisplayName(user: BapiUser): string { + const fullName = [user.first_name, user.last_name].filter(Boolean).join(" ").trim(); + return fullName || user.username || primaryIdentifier(user) || user.id; +} + +function primaryIdentifier(user: BapiUser): string { + const primaryEmail = user.email_addresses?.find( + (email) => email.id && email.id === user.primary_email_address_id, + ); + if (primaryEmail?.email_address) return primaryEmail.email_address; + + const firstEmail = user.email_addresses?.find((email) => email.email_address); + if (firstEmail?.email_address) return firstEmail.email_address; + + const primaryPhone = user.phone_numbers?.find( + (phone) => phone.id && phone.id === user.primary_phone_number_id, + ); + if (primaryPhone?.phone_number) return primaryPhone.phone_number; + + const firstPhone = user.phone_numbers?.find((phone) => phone.phone_number); + if (firstPhone?.phone_number) return firstPhone.phone_number; + + if (user.username) return user.username; + + return user.id; +} + +function formatUsersTable(users: BapiUser[]): void { + const nameWidth = + Math.max("NAME".length, ...users.map((user) => userDisplayName(user).length)) + COLUMN_PADDING; + const idWidth = + Math.max("USER ID".length, ...users.map((user) => user.id.length)) + COLUMN_PADDING; + + log.data( + `${dim("NAME".padEnd(nameWidth))}${dim("USER ID".padEnd(idWidth))}${dim("PRIMARY IDENTIFIER")}`, + ); + + for (const user of users) { + const name = cyan(userDisplayName(user).padEnd(nameWidth)); + const id = dim(user.id.padEnd(idWidth)); + log.data(`${name}${id}${primaryIdentifier(user)}`); + } +} + +export async function list(options: UsersListOptions = {}): Promise { + const secretKey = await resolveBapiSecretKey({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + const response = await withSpinner("Fetching users...", () => + bapiRequest({ + method: "GET", + path: buildUsersListPath(options), + secretKey, + }), + ); + + const body = response.body; + const users = Array.isArray(body) + ? (body as BapiUser[]) + : Array.isArray((body as PaginatedUsersResponse | undefined)?.data) + ? ((body as PaginatedUsersResponse).data as BapiUser[]) + : []; + const totalCount = + typeof (body as PaginatedUsersResponse | undefined)?.totalCount === "number" + ? (body as PaginatedUsersResponse).totalCount + : users.length; + + const jsonBody = Array.isArray(body) ? users : body; + if (printJson(jsonBody, options)) return; + + if (users.length === 0) { + log.warn("No users found."); + return; + } + + formatUsersTable(users); + log.info(`\n${totalCount} user${totalCount === 1 ? "" : "s"}`); +} diff --git a/packages/cli-core/src/test/integration/users-commands.test.ts b/packages/cli-core/src/test/integration/users-commands.test.ts index 6fc61d9c..a87cb53c 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -1,7 +1,8 @@ /** * Exercise primary users flows through the real CLI program. * Covers create against linked-project, --app, --secret-key, raw -d/--data, - * --dry-run, and the wizard picker fallback when no project is linked. + * --dry-run, and the wizard picker fallback when no project is linked, plus + * list wired up against linked-project resolution. */ import { writeFile } from "node:fs/promises"; @@ -9,6 +10,7 @@ import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { MOCK_APP, + MOCK_USERS, clerk, getInstance, http, @@ -265,4 +267,50 @@ describe("users commands", () => { expect(stderr + stdout).toContain("No input provided"); expect(findBapiCreateRequest()).toBeUndefined(); }); + + test.each([{ mode: "human" }, { mode: "agent" }])( + "lists users from linked project context ($mode mode)", + async ({ mode }) => { + await setProfile("github.com/test/project", { + workspaceId: "", + appId: MOCK_APP.application_id, + appName: MOCK_APP.name, + instances: { development: devInstance.instance_id }, + }); + + http.mock({ + "/v1/platform/applications/app_1?include_secret_keys=true": MOCK_APP, + "/v1/users": { data: MOCK_USERS, totalCount: MOCK_USERS.length }, + }); + + const { stdout, stderr } = await clerk("--mode", mode, "users", "list"); + + if (mode === "human") { + expect(stdout).toContain("John Doe"); + expect(stdout).toContain("john@example.com"); + expect(stderr).toContain("1 user"); + } else { + expect(JSON.parse(stdout)).toEqual({ + data: MOCK_USERS, + totalCount: MOCK_USERS.length, + }); + } + + expect( + http.requests.some( + (request) => + request.method === "GET" && + request.url.includes("/v1/platform/applications/app_1") && + request.url.includes("include_secret_keys=true"), + ), + ).toBe(true); + expect( + http.requests.some( + (request) => + request.method === "GET" && + request.url.includes("https://test-bapi.clerk.dev/v1/users"), + ), + ).toBe(true); + }, + ); }); From 732404d3f6f482bbdca80a11dc9b73910a967787 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 13:23:35 -0600 Subject: [PATCH 2/6] feat(users): register list in users menu with app-picker fallback Mirror `users create`: when invoked without a linked project, env var, or targeting flag, pop the shared application picker in human mode. Inside the menu's intro/outro block, route table rows to stderr so the gutter prefix is applied consistently. --- .changeset/users-list.md | 2 +- .../cli-core/src/commands/users/README.md | 2 +- .../cli-core/src/commands/users/list.test.ts | 48 +++++++++++++++++ packages/cli-core/src/commands/users/list.ts | 51 +++++++++++++++---- packages/cli-core/src/lib/log.ts | 5 ++ 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/.changeset/users-list.md b/.changeset/users-list.md index f6a91bbe..04223315 100644 --- a/.changeset/users-list.md +++ b/.changeset/users-list.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk users list` with pagination, query search, repeatable identifier filters (`--email-address`, `--phone-number`, `--username`, `--user-id`, `--external-id`), and `--order-by` covering Clerk's common user ordering fields with optional `+`/`-` prefixes. +Add `clerk users list` with pagination, query search, repeatable identifier filters (`--email-address`, `--phone-number`, `--username`, `--user-id`, `--external-id`), `--order-by` over Clerk's common user ordering fields, an entry in the interactive `clerk users` menu, and an application picker when invoked without a linked project, env var, or targeting flag. diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index a5a4ad03..6a68e65e 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -42,7 +42,7 @@ Two complementary mechanisms for JSON input work across the users command family ### `clerk users list` -List users from the target instance. +List users from the target instance. In human mode without a linked project, an env var, or a targeting flag, the command opens the same application picker as `clerk users create` so you can choose an instance interactively. ```sh clerk users list diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts index 4c03f269..2ea13221 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -1,4 +1,6 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { popPrefix, pushPrefix } from "../../lib/log.ts"; import { captureLog } from "../../test/lib/stubs.ts"; const mockBapiRequest = mock(); @@ -11,6 +13,11 @@ mock.module("../../lib/bapi-command.ts", () => ({ resolveBapiSecretKey: (...args: unknown[]) => mockResolveBapiSecretKey(...args), })); +const mockResolveUsersInstanceContext = mock(); +mock.module("./interactive/instance-context.ts", () => ({ + resolveUsersInstanceContext: (...args: unknown[]) => mockResolveUsersInstanceContext(...args), +})); + const mockWithSpinner = mock((_msg: string, fn: () => Promise) => fn()); mock.module("../../lib/spinner.ts", () => ({ withSpinner: (...args: Parameters) => mockWithSpinner(...args), @@ -75,6 +82,7 @@ describe("users list", () => { captured.teardown(); mockBapiRequest.mockReset(); mockResolveBapiSecretKey.mockReset(); + mockResolveUsersInstanceContext.mockReset(); mockWithSpinner.mockReset(); mockWithSpinner.mockImplementation((_msg: string, fn: () => Promise) => fn()); mockIsAgent.mockReset(); @@ -191,4 +199,44 @@ describe("users list", () => { totalCount: mockUsers.length, }); }); + + test("falls back to the shared picker-aware resolver in human mode when no credentials resolve", async () => { + mockResolveBapiSecretKey.mockRejectedValue( + new CliError("No secret key found.", { code: ERROR_CODE.NO_SECRET_KEY }), + ); + mockResolveUsersInstanceContext.mockResolvedValue({ secretKey: "sk_test_picked" }); + + await runList(); + + expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ instance: undefined }); + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "GET", + path: "/users", + secretKey: "sk_test_picked", + }); + }); + + test("routes the table to stderr (under the gutter) when invoked inside an intro/outro block", async () => { + pushPrefix(); + try { + await runList(); + } finally { + popPrefix(); + } + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Alice Example"); + expect(captured.err).toContain("user_123"); + expect(captured.err).toContain("alice@example.com"); + expect(captured.err).toContain("2 users"); + }); + + test("re-throws the original NO_SECRET_KEY error in agent mode without invoking the picker", async () => { + mockIsAgent.mockReturnValue(true); + const original = new CliError("No secret key found.", { code: ERROR_CODE.NO_SECRET_KEY }); + mockResolveBapiSecretKey.mockRejectedValue(original); + + await expect(runList()).rejects.toBe(original); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index c53eb544..e1de9812 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -1,9 +1,12 @@ import { resolveBapiSecretKey } from "../../lib/bapi-command.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { log } from "../../lib/log.ts"; -import { isAgent } from "../../mode.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; +import { isAgent, isHuman } from "../../mode.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { bapiRequest } from "../api/bapi.ts"; +import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; +import { registerUsersAction } from "./registry.ts"; type UsersListOptions = { json?: boolean; @@ -121,23 +124,44 @@ function formatUsersTable(users: BapiUser[]): void { const idWidth = Math.max("USER ID".length, ...users.map((user) => user.id.length)) + COLUMN_PADDING; - log.data( + // Inside an intro/outro block, route rows to stderr so the gutter prefix is + // applied. Direct invocations still get the table on stdout for piping. + const emit = isInsideGutter() + ? (line: string) => log.info(line) + : (line: string) => log.data(line); + + emit( `${dim("NAME".padEnd(nameWidth))}${dim("USER ID".padEnd(idWidth))}${dim("PRIMARY IDENTIFIER")}`, ); for (const user of users) { const name = cyan(userDisplayName(user).padEnd(nameWidth)); const id = dim(user.id.padEnd(idWidth)); - log.data(`${name}${id}${primaryIdentifier(user)}`); + emit(`${name}${id}${primaryIdentifier(user)}`); + } +} + +async function resolveListSecretKey(options: UsersListOptions): Promise { + try { + return await resolveBapiSecretKey({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + } catch (error) { + // Mirror `users create`: when there is no link, no env var, and no + // targeting flags, fall back to the shared picker-aware resolver in human + // mode so the user can choose an application interactively. + if (isHuman() && error instanceof CliError && error.code === ERROR_CODE.NO_SECRET_KEY) { + const ctx = await resolveUsersInstanceContext({ instance: options.instance }); + return ctx.secretKey; + } + throw error; } } export async function list(options: UsersListOptions = {}): Promise { - const secretKey = await resolveBapiSecretKey({ - secretKey: options.secretKey, - app: options.app, - instance: options.instance, - }); + const secretKey = await resolveListSecretKey(options); const response = await withSpinner("Fetching users...", () => bapiRequest({ method: "GET", @@ -168,3 +192,12 @@ export async function list(options: UsersListOptions = {}): Promise { formatUsersTable(users); log.info(`\n${totalCount} user${totalCount === 1 ? "" : "s"}`); } + +registerUsersAction({ + key: "list", + label: "List users", + description: "List users with filters and pagination", + handler: async (targeting) => { + await list(targeting); + }, +}); diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index d2b2f957..3530f132 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -40,6 +40,11 @@ export function popPrefix() { prefixDepth = Math.max(0, prefixDepth - 1); } +/** True while an intro/outro block is active and stderr output is gutter-prefixed. */ +export function isInsideGutter(): boolean { + return prefixDepth > 0; +} + function applyPrefix(msg: string): string { if (prefixDepth === 0) return msg; const bar = dim(S_BAR); From 651ecf0405e78735254aced4296c816cd4ea50df Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 14:23:05 -0600 Subject: [PATCH 3/6] refactor(users): trim --order-by choices to displayed and timestamp fields Drop web3wallet, updated_at, and last_active_at from the --order-by choice list. Keeps the five identifier fields rendered in the users list table plus created_at and last_sign_in_at, the two timestamps users actually sort by even though they aren't displayed. --- packages/cli-core/src/cli-program.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 670ba77c..68a9bb88 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -49,14 +49,11 @@ import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; const USER_LIST_ORDER_BY_FIELDS = [ "created_at", - "updated_at", "email_address", - "web3wallet", "first_name", "last_name", "phone_number", "username", - "last_active_at", "last_sign_in_at", ] as const; From 503e9d8521d55facaea10b740464e83da9171835 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 15:12:46 -0600 Subject: [PATCH 4/6] docs(skill): point users recipes at `clerk users list` The list, search-by-email, and `jq`-pipe scripting examples in the bundled clerk skill's `recipes.md` defaulted to raw `clerk api /users[?...]` calls. With `clerk users list` available, switch them to the curated command (with `--json` for the pipe forms) so agents reach for the dedicated subcommand by default. Count, fetch, update, and lifecycle recipes stay on raw API since their curated subcommands are still upstack. --- skills/clerk/references/recipes.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/skills/clerk/references/recipes.md b/skills/clerk/references/recipes.md index ba9e0d86..856c825c 100644 --- a/skills/clerk/references/recipes.md +++ b/skills/clerk/references/recipes.md @@ -15,18 +15,18 @@ The bundled catalog is cached locally for 1 hour; run `clerk api ls` to force a ## Users ```sh -# List users (paginated) -clerk api /users -clerk api '/users?limit=10&offset=0&order_by=-created_at' +# List users (preferred; curated flags) +clerk users list +clerk users list --limit 10 --offset 0 --order-by -created_at -# Count users +# Count users (no curated subcommand; use the raw API) clerk api /users/count -# Fetch a user +# Fetch a user (no curated subcommand; use the raw API) clerk api /users/user_abc123 # Search by email -clerk api '/users?email_address=alice@example.com' +clerk users list --email-address alice@example.com # Create a user (preferred; curated flags) clerk users create \ @@ -217,10 +217,10 @@ clerk api /v1/platform/applications/app_abc123 --platform ```sh # Get a list of user IDs -clerk api /users | jq -r '.[] | .id' +clerk users list --json | jq -r '.[] | .id' # Count banned users -clerk api /users | jq '[.[] | select(.banned)] | length' +clerk users list --json | jq '[.[] | select(.banned)] | length' ``` ### Read body from stdin @@ -234,7 +234,7 @@ jq -n '{email_address:["c@d.co"]}' | clerk api /users ```sh # Always --dry-run first across the whole set -for id in $(clerk api /users | jq -r '.[] | .id'); do +for id in $(clerk users list --json | jq -r '.[] | .id'); do clerk api /users/$id -X PATCH -d '{"public_metadata":{"migrated":true}}' --dry-run done # Re-run without --dry-run once the previews look right From 13bc1e25420349d192fa830a9fbfc6fd2a6fa6ad Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 15:46:44 -0600 Subject: [PATCH 5/6] fix(users): thread parent options into list and menu actions Both the `users` parent command and its `list` subcommand define `--secret-key` / `--app` / `--instance`. When a subcommand redefines an option already set on its parent, commander attributes the parsed value to the parent's option object, so a child's bare `cmd.opts()` returns empty for those keys. The list action handler relied on commander's default invocation (which passes only local opts), so `clerk users list --app ` and `clerk users --app ` (interactive menu) silently dropped the targeting flags and fell through to the no-secret-key error, even though the documented usage in `users/README.md` includes both. Mirror the create subcommand's wiring: replace the bare `.action(handler)` with `.action((_opts, cmd) => handler(cmd.optsWithGlobals()))` for both the `users` parent menu action and the `users list` action. `create` already uses this pattern and works correctly. Adds an integration test covering the regression: `clerk users list --app app_1 --instance development` resolves the secret key via the Platform API without a linked project, then issues the BAPI list call. --- packages/cli-core/src/cli-program.ts | 8 +++-- .../test/integration/users-commands.test.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 68a9bb88..0be9ecfd 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -315,7 +315,9 @@ Give AI agents better Clerk context: install the Clerk skills description: "Create a user from an inline BAPI request body", }, ]) - .action(usersHandlers.menu); + .action((_opts, cmd) => + usersHandlers.menu(cmd.optsWithGlobals() as Parameters[0]), + ); users .command("list") @@ -379,7 +381,9 @@ Give AI agents better Clerk context: install the Clerk skills description: "Filter by common identifiers and sort by recent sign-in", }, ]) - .action(usersHandlers.list); + .action((_opts, cmd) => + usersHandlers.list(cmd.optsWithGlobals() as Parameters[0]), + ); users .command("create") diff --git a/packages/cli-core/src/test/integration/users-commands.test.ts b/packages/cli-core/src/test/integration/users-commands.test.ts index a87cb53c..3223be16 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -313,4 +313,36 @@ describe("users commands", () => { ).toBe(true); }, ); + + test("list resolves the secret key via --app without a linked project", async () => { + // No setProfile call: --app must thread through the parent command's + // option, not silently fall through to the no-secret-key error. + http.mock({ + [PLAPI_APP_ROUTE]: MOCK_APP, + "/v1/users": { data: MOCK_USERS, totalCount: MOCK_USERS.length }, + }); + + const { stdout, exitCode } = await clerk.raw( + "--mode", + "agent", + "users", + "list", + "--app", + "app_1", + "--instance", + "development", + ); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual({ + data: MOCK_USERS, + totalCount: MOCK_USERS.length, + }); + + const fetchAppCall = http.requests.find( + (request) => + request.method === "GET" && request.url.includes("/v1/platform/applications/app_1"), + ); + expect(fetchAppCall).toBeDefined(); + }); }); From 35d2af577286ca9ac40a82f3f9c14812ad20e939 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 16:13:54 -0600 Subject: [PATCH 6/6] fix(users): keep --app/--instance from picker fallback on missing key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clerk users list` would silently switch applications if a user passed `--app ` (or other explicit targeting) and the resolved instance had no secret key — `resolveBapiSecretKey` threw NO_SECRET_KEY, and the catch opened the picker without preserving the explicit target. Surface the original error in those cases; only fall back to the picker when no target was provided. --- .../cli-core/src/commands/users/list.test.ts | 17 ++++++++++++++++- packages/cli-core/src/commands/users/list.ts | 19 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts index 2ea13221..db6eff7f 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -208,7 +208,7 @@ describe("users list", () => { await runList(); - expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ instance: undefined }); + expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({}); expect(mockBapiRequest).toHaveBeenCalledWith({ method: "GET", path: "/users", @@ -239,4 +239,19 @@ describe("users list", () => { await expect(runList()).rejects.toBe(original); expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); }); + + test.each([ + { label: "--app", options: { app: "app_123" } }, + { label: "--instance", options: { instance: "prod" } }, + { label: "--secret-key", options: { secretKey: "sk_test_explicit" } }, + ])( + "re-throws NO_SECRET_KEY without invoking the picker when $label is set", + async ({ options }) => { + const original = new CliError("No secret key found.", { code: ERROR_CODE.NO_SECRET_KEY }); + mockResolveBapiSecretKey.mockRejectedValue(original); + + await expect(runList(options)).rejects.toBe(original); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + }, + ); }); diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index e1de9812..fac22fe0 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -151,9 +151,22 @@ async function resolveListSecretKey(options: UsersListOptions): Promise } catch (error) { // Mirror `users create`: when there is no link, no env var, and no // targeting flags, fall back to the shared picker-aware resolver in human - // mode so the user can choose an application interactively. - if (isHuman() && error instanceof CliError && error.code === ERROR_CODE.NO_SECRET_KEY) { - const ctx = await resolveUsersInstanceContext({ instance: options.instance }); + // mode so the user can choose an application interactively. With an + // explicit target the user already chose where to operate; surface the + // original error instead of silently switching applications. + const hasExplicitTarget = + Boolean(options.secretKey) || + Boolean(options.app) || + Boolean(options.instance) || + Boolean(process.env.CLERK_SECRET_KEY); + + if ( + isHuman() && + !hasExplicitTarget && + error instanceof CliError && + error.code === ERROR_CODE.NO_SECRET_KEY + ) { + const ctx = await resolveUsersInstanceContext({}); return ctx.secretKey; } throw error;