Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/users-list.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 27 additions & 2 deletions packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
109 changes: 108 additions & 1 deletion packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -267,6 +305,7 @@ Give AI agents better Clerk context: install the Clerk skills
.option("--app <id>", "Application ID to target (works from any directory)")
.option("--instance <id>", "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",
Expand All @@ -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<typeof usersHandlers.menu>[0]),
);

users
.command("list")
.description("List users")
.option("--json", "Output as JSON")
.option("--limit <number>", "Maximum users to return (1-500)", (value) =>
parseIntegerOption(value, "--limit", { min: 1, max: 500 }),
)
.option("--offset <number>", "Users to skip before returning results (0+)", (value) =>
parseIntegerOption(value, "--offset", { min: 0 }),
)
.option("--query <query>", "Search across common user fields")
.option(
"--email-address <email>",
"Filter by email address (repeat or comma-separate)",
collectOptionValues,
[],
)
.option(
"--phone-number <phone>",
"Filter by phone number (repeat or comma-separate)",
collectOptionValues,
[],
)
.option(
"--username <username>",
"Filter by username (repeat or comma-separate)",
collectOptionValues,
[],
)
.option(
"--user-id <user-id>",
"Filter by user ID (repeat or comma-separate)",
collectOptionValues,
[],
)
.option(
"--external-id <external-id>",
"Filter by external ID (repeat or comma-separate)",
collectOptionValues,
[],
)
.addOption(
createOption(
"--order-by <field>",
"Order by a supported field, optionally prefixed with + or -",
).choices(USER_LIST_ORDER_BY_CHOICES),
)
.option("--secret-key <key>", "Backend API secret key to use")
.option("--app <id>", "Application ID to target (works from any directory)")
.option("--instance <id>", "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<typeof usersHandlers.list>[0]),
);

users
.command("create")
Expand Down
26 changes: 26 additions & 0 deletions packages/cli-core/src/commands/users/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number>`
- `--offset <number>`
- `--query <query>`
- `--email-address <email>` repeat or comma-separate values
- `--phone-number <phone>` repeat or comma-separate values
- `--username <username>` repeat or comma-separate values
- `--user-id <user-id>` repeat or comma-separate values
- `--external-id <external-id>` repeat or comma-separate values
- `--order-by <field>` 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.
Expand Down Expand Up @@ -68,6 +93,7 @@ Supported curated flags:

| Method | Endpoint | Command(s) |
| ------ | ----------- | ---------- |
| `GET` | `/v1/users` | `list` |
| `POST` | `/v1/users` | `create` |

## Notes
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-core/src/commands/users/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,5 +11,6 @@ export {

export const users = {
create,
list,
menu: usersMenu,
};
Loading
Loading