diff --git a/.changeset/users-list.md b/.changeset/users-list.md new file mode 100644 index 00000000..04223315 --- /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`), `--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/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..0be9ecfd 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -47,6 +47,44 @@ 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", + "email_address", + "first_name", + "last_name", + "phone_number", + "username", + "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 +305,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", @@ -276,7 +315,75 @@ 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") + .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((_opts, cmd) => + usersHandlers.list(cmd.optsWithGlobals() as Parameters[0]), + ); users .command("create") diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 11ee9d82..6a68e65e 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. 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 +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..db6eff7f --- /dev/null +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -0,0 +1,257 @@ +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(); +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 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), +})); + +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(); + mockResolveUsersInstanceContext.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, + }); + }); + + 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({}); + 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(); + }); + + 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 new file mode 100644 index 00000000..fac22fe0 --- /dev/null +++ b/packages/cli-core/src/commands/users/list.ts @@ -0,0 +1,216 @@ +import { resolveBapiSecretKey } from "../../lib/bapi-command.ts"; +import { dim, cyan } from "../../lib/color.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; + 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; + + // 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)); + 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. 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; + } +} + +export async function list(options: UsersListOptions = {}): Promise { + const secretKey = await resolveListSecretKey(options); + 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"}`); +} + +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); 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..3223be16 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,82 @@ 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); + }, + ); + + 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(); + }); }); 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