From 0924ac500538cd089bb84d1f22a67ff09dbb85e6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 25 May 2026 19:35:26 -0300 Subject: [PATCH 1/2] refactor(cli): co-locate command registration in each command folder Move Commander wiring out of the monolithic cli-program.ts into per-command registerX(program) functions co-located with each command. createProgram() now just calls them in registration order, shrinking cli-program.ts from 1109 to ~320 lines and making each command's surface owned by its own folder. - Each command folder exposes registerX(program): void (mirrors the existing registerExtras precedent) - New commands/toggles/ owns the shared enable/disable parents that wire the orgs + billing handlers (neither feature folder cleanly owns them) - Shared option parsers (parseIntegerOption, collectOptionValues) extracted to lib/option-parsers.ts - clerk --help output verified byte-identical across all 39 command nodes; no user-facing change --- .changeset/fancy-frogs-open.md | 2 + packages/cli-core/src/cli-program.ts | 851 +----------------- packages/cli-core/src/commands/api/index.ts | 32 + packages/cli-core/src/commands/apps/index.ts | 26 +- packages/cli-core/src/commands/auth/index.ts | 49 + .../cli-core/src/commands/completion/index.ts | 45 + .../cli-core/src/commands/config/index.ts | 143 +++ .../cli-core/src/commands/deploy/index.ts | 14 + .../cli-core/src/commands/doctor/index.ts | 19 + packages/cli-core/src/commands/env/index.ts | 28 + packages/cli-core/src/commands/init/index.ts | 54 +- packages/cli-core/src/commands/link/index.ts | 13 + packages/cli-core/src/commands/open/index.ts | 20 + packages/cli-core/src/commands/skill/index.ts | 35 + .../cli-core/src/commands/switch-env/index.ts | 14 + .../cli-core/src/commands/toggles/README.md | 22 + .../cli-core/src/commands/toggles/index.ts | 158 ++++ .../cli-core/src/commands/unlink/index.ts | 13 + .../cli-core/src/commands/update/index.ts | 20 + packages/cli-core/src/commands/users/index.ts | 168 +++- .../cli-core/src/commands/whoami/index.ts | 13 + packages/cli-core/src/lib/option-parsers.ts | 25 + 22 files changed, 954 insertions(+), 810 deletions(-) create mode 100644 .changeset/fancy-frogs-open.md create mode 100644 packages/cli-core/src/commands/auth/index.ts create mode 100644 packages/cli-core/src/commands/config/index.ts create mode 100644 packages/cli-core/src/commands/env/index.ts create mode 100644 packages/cli-core/src/commands/skill/index.ts create mode 100644 packages/cli-core/src/commands/toggles/README.md create mode 100644 packages/cli-core/src/commands/toggles/index.ts create mode 100644 packages/cli-core/src/lib/option-parsers.ts diff --git a/.changeset/fancy-frogs-open.md b/.changeset/fancy-frogs-open.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/fancy-frogs-open.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index bef0fcce..c6fff179 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1,23 +1,25 @@ -import { Command, createOption, createArgument } from "@commander-js/extra-typings"; +import { Command } from "@commander-js/extra-typings"; import { expandInputJson } from "./lib/input-json.ts"; import { setLogLevel } from "./lib/log.ts"; import { setMode, type Mode } from "./mode.ts"; -import { init } from "./commands/init/index.ts"; -import { login } from "./commands/auth/login.ts"; -import { logout } from "./commands/auth/logout.ts"; -import { whoami } from "./commands/whoami/index.ts"; -import { pull } from "./commands/env/pull.ts"; -import { configPull } from "./commands/config/pull.ts"; -import { configPatch, configPut } from "./commands/config/push.ts"; -import { configSchema } from "./commands/config/schema.ts"; -import { api } from "./commands/api/index.ts"; -import { link } from "./commands/link/index.ts"; -import { unlink } from "./commands/unlink/index.ts"; -import { apps as appsHandlers } from "./commands/apps/index.ts"; -import { users as usersHandlers } from "./commands/users/index.ts"; -import { doctor } from "./commands/doctor/index.ts"; -import { switchEnv } from "./commands/switch-env/index.ts"; -import { openDashboard } from "./commands/open/index.ts"; +import { registerInit } from "./commands/init/index.ts"; +import { registerAuth } from "./commands/auth/index.ts"; +import { registerLink } from "./commands/link/index.ts"; +import { registerUnlink } from "./commands/unlink/index.ts"; +import { registerWhoami } from "./commands/whoami/index.ts"; +import { registerOpen } from "./commands/open/index.ts"; +import { registerApps } from "./commands/apps/index.ts"; +import { registerUsers } from "./commands/users/index.ts"; +import { registerEnv } from "./commands/env/index.ts"; +import { registerConfig } from "./commands/config/index.ts"; +import { registerToggles } from "./commands/toggles/index.ts"; +import { registerApi } from "./commands/api/index.ts"; +import { registerDoctor } from "./commands/doctor/index.ts"; +import { registerSwitchEnv } from "./commands/switch-env/index.ts"; +import { registerCompletion } from "./commands/completion/index.ts"; +import { registerSkill } from "./commands/skill/index.ts"; +import { registerUpdate } from "./commands/update/index.ts"; +import { registerDeploy } from "./commands/deploy/index.ts"; import { getEnvironment } from "./lib/config.ts"; import { setCurrentEnv, @@ -26,9 +28,6 @@ import { getAvailableEnvs, getPlapiBaseUrl, } from "./lib/environment.ts"; -import { completion, SUPPORTED_SHELLS } from "./commands/completion/index.ts"; -import { FRAMEWORK_NAMES } from "./lib/framework.ts"; -import { PACKAGE_MANAGERS } from "./lib/package-manager.ts"; import { CliError, UserAbortError, @@ -43,50 +42,31 @@ import { clerkHelpConfig } from "./lib/help.ts"; import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; -import { update } from "./commands/update/index.ts"; -import { deploy } from "./commands/deploy/index.ts"; -import { deployStatus } from "./commands/deploy/status-command.ts"; -import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; -import { billingEnable, billingDisable } from "./commands/billing/index.ts"; import { registerExtras } from "@clerk/cli-extras"; -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; -} +type CommandRegistrant = (program: Command) => void; + +const registrants: CommandRegistrant[] = [ + registerInit, + registerAuth, + registerLink, + registerUnlink, + registerWhoami, + registerOpen, + registerApps, + registerUsers, + registerEnv, + registerConfig, + registerToggles, + registerApi, + registerDoctor, + registerSwitchEnv, + registerCompletion, + registerSkill, + registerUpdate, + registerDeploy, + registerExtras, +]; export function createProgram() { const program = new Command() @@ -155,750 +135,9 @@ export function createProgram() { await maybeNotifyUpdate(getCurrentVersion()); }); - program - .command("init") - .description("Initialize Clerk in your project") - .addOption( - createOption("--framework ", "Framework to set up (skips auto-detection)").choices( - FRAMEWORK_NAMES, - ), - ) - .addOption( - createOption( - "--pm ", - "Package manager to use (skips prompt/auto-detection)", - ).choices(PACKAGE_MANAGERS), - ) - .option("--name ", "Project name for --starter (skips prompt)") - .option("--app ", "Application ID to link (skips interactive picker)") - .option("--starter", "Create a new project from a starter template") - .option( - "--keyless", - "Use keyless development keys instead of logging in (only for keyless-capable frameworks)", - ) - .option("-y, --yes", "Skip confirmation prompts") - .option("--no-skills", "Skip the optional agent skills install prompt") - .setExamples([ - { command: "clerk init", description: "Auto-detect framework and set up Clerk" }, - { - command: "clerk init --framework next", - description: "Set up for Next.js (skips detection)", - }, - { - command: "clerk init --app app_123", - description: "Link to a specific Clerk application", - }, - { command: "clerk init --starter", description: "Create a new project with Clerk" }, - { - command: "clerk init --starter --framework next --pm bun", - description: "Bootstrap with Bun", - }, - { - command: "clerk init --starter --framework next --keyless", - description: "Bootstrap without logging in (uses temporary dev keys)", - }, - { command: "clerk init -y", description: "Skip all confirmation prompts" }, - { command: "clerk init --no-skills", description: "Skip the agent skills install prompt" }, - ]) - .action(init); - - const auth = program - .command("auth") - .description("Manage authentication") - .setExamples([ - { command: "clerk auth login", description: "Log in via browser (OAuth)" }, - { command: "clerk auth logout", description: "Remove stored credentials" }, - ]); - - auth - .command("login") - .aliases(["signup", "signin", "sign-in"]) - .description("Log in to your Clerk account") - .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") - .setExamples([ - { command: "clerk auth login", description: "Log in via browser (OAuth)" }, - { - command: "clerk auth login -y", - description: "Re-authenticate via OAuth without confirmation when already signed in", - }, - ]) - .action(async (opts) => { - await login(opts); - }); - - auth - .command("logout") - .aliases(["signout", "sign-out"]) - .description("Log out of your Clerk account") - .setExamples([{ command: "clerk auth logout", description: "Remove stored credentials" }]) - .action(logout); - - program - .command("login", { hidden: true }) - .description("Log in to your Clerk account") - .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") - .action(async (opts) => { - await login(opts); - }); - - program - .command("logout", { hidden: true }) - .description("Log out of your Clerk account") - .action(logout); - - program - .command("link") - .description("Link this project to a Clerk application") - .option("--app ", "Application ID to link (skips interactive picker)") - .setExamples([ - { command: "clerk link", description: "Pick an app interactively" }, - { command: "clerk link --app app_abc123", description: "Link directly by application ID" }, - ]) - .action(link); - - program - .command("unlink") - .description("Unlink this project from its Clerk application") - .option("--yes", "Skip confirmation prompt") - .setExamples([ - { command: "clerk unlink", description: "Unlink with confirmation prompt" }, - { command: "clerk unlink --yes", description: "Skip confirmation" }, - ]) - .action(unlink); - - program - .command("whoami") - .description("Show the current logged-in user and linked application") - .option("--json", "Output JSON") - .setExamples([ - { command: "clerk whoami", description: "Show your email and linked app" }, - { command: "clerk whoami --json", description: "Emit a structured payload on stdout" }, - ]) - .action((options) => whoami({ json: options.json })); - - const open = program.command("open").description("Open Clerk resources in your browser"); - - open - .command("dashboard", { isDefault: true }) - .description("Open the linked app's dashboard in your browser") - .addArgument( - createArgument("[subpath]", "Optional dashboard subpath (e.g. users, api-keys, settings)"), - ) - .option("--print", "Print the URL without opening the browser") - .setExamples([ - { command: "clerk open", description: "Open the linked app's dashboard" }, - { command: "clerk open users", description: "Open the users page" }, - { command: "clerk open api-keys", description: "Open the API keys page" }, - { command: "clerk open --print", description: "Print the dashboard URL" }, - ]) - .action((subpath, options) => openDashboard(subpath, options)); - - const apps = program.command("apps").description("Manage your Clerk applications"); - - apps - .command("list") - .description("List your Clerk applications") - .option("--json", "Output as JSON") - .setExamples([ - { command: "clerk apps list", description: "List all applications" }, - { command: "clerk apps list --json", description: "Output as JSON" }, - ]) - .action(appsHandlers.list); - - apps - .command("create") - .description("Create a new Clerk application") - .argument("", "Application name") - .option("--json", "Output as JSON") - .setExamples([ - { command: 'clerk apps create "My App"', description: "Create a new application" }, - { command: 'clerk apps create "My App" --json', description: "Output as JSON" }, - ]) - .action(appsHandlers.create); - - const users = program - .command("users") - .description("Manage Clerk users") - .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" }, - { - command: "clerk users create --email alice@example.com --first-name Alice --yes", - description: "Create a user from curated flags", - }, - { - command: 'clerk users create -d \'{"email_address":["alice@example.com"]}\' --yes', - description: "Create a user from an inline BAPI request body", - }, - ]) - .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-250, default 100)", (value) => - parseIntegerOption(value, "--limit", { min: 1, max: 250 }), - ) - .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") - .description("Create a user") - .option("--json", "Output as JSON") - .option("--email ", "Email address") - .option("--phone ", "Phone number") - .option("--username ", "Username") - .option("--password ", "Password") - .option("--first-name ", "First name") - .option("--last-name ", "Last name") - .option("--external-id ", "External ID") - .option("-d, --data ", "Inline BAPI request body") - .option("--file ", "Read BAPI request body from a file") - .option("--dry-run", "Show the request without executing it") - .option("--yes", "Skip confirmation prompt") - .setExamples([ - { - command: "clerk users create --email alice@example.com --first-name Alice --yes", - description: "Create a user from curated flags", - }, - { - command: 'clerk users create -d \'{"email_address":["alice@example.com"]}\' --yes', - description: "Create a user from an inline BAPI request body", - }, - { - command: "clerk users create --file user.json --dry-run", - description: "Preview a request from a file without executing", - }, - ]) - .action((_opts, cmd) => - usersHandlers.create(cmd.optsWithGlobals() as Parameters[0]), - ); - - users - .command("open") - .description("Open a user's dashboard page in your browser") - .addArgument(createArgument("[user-id]", "User ID to open. Omit to pick interactively.")) - .option("--print", "Print the URL without opening the browser") - .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 open", description: "Pick app (if not linked) and user, then open" }, - { - command: "clerk users open user_2x9k", - description: "Open a specific user (pick app if not linked)", - }, - { - command: "clerk users open user_2x9k --app app_123", - description: "Open a specific user against an explicit app", - }, - { - command: "clerk users open user_2x9k --print", - description: "Print the dashboard URL instead of opening", - }, - ]) - .action((userId, _opts, cmd) => - usersHandlers.open({ - ...(cmd.optsWithGlobals() as Parameters[0]), - userId, - }), - ); - - const env = program - .command("env") - .description("Manage environment variables") - .setExamples([ - { command: "clerk env pull", description: "Pull dev keys to .env.local" }, - { command: "clerk env pull --instance prod", description: "Pull production keys" }, - { command: "clerk env pull --file .env", description: "Write to a specific file" }, - { command: "clerk env pull --app app_abc123", description: "Target a specific application" }, - ]); - - env - .command("pull") - .description("Pull environment variables from Clerk to .env.local") - .option("--app ", "Application ID to target (works from any directory)") - .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") - .option("--file ", "Target env file (default: auto-detect)") - .setExamples([ - { command: "clerk env pull", description: "Pull dev keys to .env.local" }, - { command: "clerk env pull --instance prod", description: "Pull production keys" }, - { command: "clerk env pull --file .env", description: "Write to a specific file" }, - { command: "clerk env pull --app app_abc123", description: "Target a specific application" }, - ]) - .action(pull); - - const config = program - .command("config") - .description("Manage instance configuration") - .setExamples([ - { command: "clerk config pull", description: "Print dev config to stdout" }, - { command: "clerk config pull --instance prod", description: "Pull production config" }, - { command: "clerk config pull --output config.json", description: "Save config to a file" }, - { command: "clerk config schema", description: "Print full config schema" }, - { - command: "clerk config schema --keys auth_email session", - description: "Schema for specific top-level keys", - }, - { - command: "clerk config patch --file config.json", - description: "Apply partial update from file", - }, - { - command: 'clerk config patch --json \'{"key":"value"}\'', - description: "Inline JSON patch", - }, - { - command: "clerk config patch --file config.json --dry-run", - description: "Preview without applying", - }, - { - command: "clerk config put --file config.json", - description: "Replace entire config from file", - }, - { - command: "clerk config put --instance prod --file config.json", - description: "Replace production config", - }, - ]); - - config - .command("pull") - .description("Pull instance configuration from Clerk") - .option("--app ", "Application ID to target (works from any directory)") - .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") - .option("--output ", "Write config to a file instead of stdout") - .option( - "--keys ", - "Top-level config keys to retrieve, separated by spaces or commas (e.g. auth_email session)", - ) - .setExamples([ - { command: "clerk config pull", description: "Print dev config to stdout" }, - { command: "clerk config pull --instance prod", description: "Pull production config" }, - { command: "clerk config pull --output config.json", description: "Save config to a file" }, - ]) - .action(configPull); - - config - .command("schema") - .description("Pull instance config schema from Clerk") - .option("--app ", "Application ID to target (works from any directory)") - .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") - .option("--output ", "Write schema to a file instead of stdout") - .option( - "--keys ", - "Top-level schema sections to retrieve, separated by spaces or commas (e.g. auth_email session)", - ) - .setExamples([ - { command: "clerk config schema", description: "Print full config schema" }, - { - command: "clerk config schema --keys auth_email session", - description: "Schema for specific top-level keys", - }, - { command: "clerk config schema --output schema.json", description: "Save schema to a file" }, - ]) - .action(configSchema); - - config - .command("patch") - .description("Partially update instance configuration (PATCH)") - .option("--app ", "Application ID to target (works from any directory)") - .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") - .option("--file ", "Read config JSON from a file") - .option("--json ", "Pass config JSON inline") - .option("--dry-run", "Show what would be sent without making the API call") - .option("--yes", "Skip confirmation prompts") - .option( - "--destructive", - "Allow destructive changes that delete resources (e.g. session templates, custom OAuth providers) rather than just resetting config to defaults", - ) - .setExamples([ - { - command: "clerk config patch --file config.json", - description: "Apply partial update from file", - }, - { - command: 'clerk config patch --json \'{"key":"value"}\'', - description: "Inline JSON patch", - }, - { - command: "clerk config patch --file config.json --dry-run", - description: "Preview without applying", - }, - { - command: "clerk config patch --instance prod --file config.json", - description: "Patch production config", - }, - ]) - .action(configPatch); - - config - .command("put") - .description("Replace entire instance configuration (PUT)") - .option("--app ", "Application ID to target (works from any directory)") - .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") - .option("--file ", "Read config JSON from a file") - .option("--json ", "Pass config JSON inline") - .option("--dry-run", "Show what would be sent without making the API call") - .option("--yes", "Skip confirmation prompts") - .option( - "--destructive", - "Allow destructive changes that delete resources (e.g. session templates, custom OAuth providers) rather than just resetting config to defaults", - ) - .setExamples([ - { - command: "clerk config put --file config.json", - description: "Replace entire config from file", - }, - { - command: "clerk config put --file config.json --dry-run", - description: "Preview the replacement", - }, - { - command: "clerk config put --instance prod --file config.json", - description: "Replace production config", - }, - { - command: "clerk config put --file config.json --yes", - description: "Skip confirmation prompt", - }, - ]) - .action(configPut); - - // --- clerk enable / disable --- - const enable = program - .command("enable") - .description("Enable Clerk features on the linked instance") - .setExamples([ - { command: "clerk enable orgs", description: "Enable organizations" }, - { - command: "clerk enable orgs --force-selection --max-members 10", - description: "Enable organizations with options", - }, - { - command: "clerk enable billing --for orgs", - description: "Enable billing for organizations only", - }, - { - command: "clerk enable billing", - description: "Enable billing for organizations and users", - }, - ]); - - enable - .command("orgs") - .alias("organizations") - .description("Enable organizations on the linked instance") - .option("--app ", "Application ID to target") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .option("--force-selection", "Force organization selection on login") - .option("--auto-create", "Auto-create an organization for new users") - .option("--max-members ", "Maximum members per organization") - .option("--domains", "Enable verified domains") - .option("--yes", "Skip confirmation prompts") - .option("--dry-run", "Show the patch that would be sent without applying it") - .setExamples([ - { command: "clerk enable orgs", description: "Enable organizations" }, - { - command: "clerk enable orgs --force-selection", - description: "Enable and force org selection", - }, - { - command: "clerk enable orgs --auto-create --max-members 10", - description: "Enable with auto-creation and member limit", - }, - { - command: "clerk enable orgs --dry-run", - description: "Preview the patch without applying it", - }, - ]) - .action(orgsEnable); - - enable - .command("billing") - .description("Enable billing for organizations and/or users") - .option( - "--for ", - "Billing targets (orgs and/or users), separated by spaces or commas (e.g. orgs users). Defaults to both when omitted.", - ) - .option("--app ", "Application ID to target") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .option("--yes", "Skip confirmation prompts") - .option("--dry-run", "Show the patch that would be sent without applying it") - .option("--no-skills", "Skip the optional `clerk-billing` agent skill install") - .setExamples([ - { - command: "clerk enable billing", - description: "Enable billing for organizations and users", - }, - { - command: "clerk enable billing --for orgs", - description: "Enable billing for organizations only", - }, - { - command: "clerk enable billing --for users", - description: "Enable billing for users only", - }, - { - command: "clerk enable billing --for orgs users", - description: "Enable billing for both targets", - }, - { - command: "clerk enable billing --no-skills", - description: "Enable without installing the agent skill", - }, - ]) - .action(billingEnable); - - const disable = program - .command("disable") - .description("Disable Clerk features on the linked instance") - .setExamples([ - { command: "clerk disable orgs", description: "Disable organizations" }, - { - command: "clerk disable billing --for orgs", - description: "Disable billing for organizations only (leaves organizations enabled)", - }, - { - command: "clerk disable billing", - description: "Disable billing for organizations and users", - }, - ]); - - disable - .command("orgs") - .alias("organizations") - .description("Disable organizations on the linked instance") - .option("--app ", "Application ID to target") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .option("--yes", "Skip confirmation prompts") - .option("--dry-run", "Show the patch that would be sent without applying it") - .setExamples([ - { command: "clerk disable orgs", description: "Disable organizations" }, - { - command: "clerk disable orgs --dry-run", - description: "Preview without applying", - }, - ]) - .action(orgsDisable); - - disable - .command("billing") - .description( - "Disable billing for organizations and/or users (does not disable organizations themselves)", - ) - .option( - "--for ", - "Billing targets (orgs and/or users), separated by spaces or commas (e.g. orgs users). Defaults to both when omitted.", - ) - .option("--app ", "Application ID to target") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .option("--yes", "Skip confirmation prompts") - .option("--dry-run", "Show the patch that would be sent without applying it") - .setExamples([ - { - command: "clerk disable billing", - description: "Disable billing for organizations and users", - }, - { - command: "clerk disable billing --for orgs", - description: "Disable billing for organizations only", - }, - { - command: "clerk disable billing --for users", - description: "Disable billing for users only", - }, - ]) - .action(billingDisable); - - program - .command("api") - .description("Make authenticated requests to the Clerk API") - .argument( - "[endpoint]", - "API endpoint path, 'ls' to list endpoints, or omit for interactive mode", - ) - .argument("[filter]", "Filter keyword (used with 'ls')") - .option("-X, --method ", "HTTP method (default: GET, or POST if body provided)") - .option("-d, --data ", "JSON request body") - .option("--file ", "Read request body from a file") - .option("--include", "Show response headers") - .option("--app ", "Application ID to target when resolving keys") - .option("--secret-key ", "Override the secret key") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .option("--platform", "Use Platform API instead of Backend API") - .option("--dry-run", "Show the request without executing it") - .option("--yes", "Skip confirmation for mutating requests") - .setExamples([ - { command: "clerk api ls", description: "List all available endpoints" }, - { command: "clerk api ls users", description: 'List endpoints matching "users"' }, - { command: "clerk api /users", description: "GET /v1/users" }, - { - command: 'clerk api /users -d \'{"first_name":"Alice"}\'', - description: "POST with a JSON body", - }, - ]) - .action(api); - - program - .command("doctor") - .description("Check your project's Clerk integration health") - .option("--verbose", "Show detailed output for each check") - .option("--json", "Output results as JSON") - .option("--spotlight", "Only show warnings and failures") - .option("--fix", "Attempt to auto-fix issues") - .setExamples([ - { command: "clerk doctor", description: "Run all health checks" }, - { command: "clerk doctor --verbose", description: "Show detailed output for each check" }, - { command: "clerk doctor --json", description: "Output results as machine-readable JSON" }, - { command: "clerk doctor --fix", description: "Auto-fix detected issues" }, - { command: "clerk doctor --spotlight", description: "Only show warnings and failures" }, - ]) - .action(doctor); - - program - .command("switch-env", { hidden: true }) - .description("Switch the active Clerk CLI environment") - .argument("[environment]", "Environment to switch to (e.g. production, staging)") - .setExamples([ - { command: "clerk switch-env", description: "Show current environment" }, - { command: "clerk switch-env staging", description: "Switch to staging" }, - { command: "clerk switch-env production", description: "Switch back to production" }, - ]) - .action(switchEnv); - - program - .command("completion") - .description("Generate shell autocompletion script") - .addArgument( - createArgument("[shell]", `Shell type (${SUPPORTED_SHELLS.join(", ")})`).choices( - SUPPORTED_SHELLS, - ), - ) - .setExamples([ - { command: "clerk completion bash", description: "Output bash completion script" }, - { command: "clerk completion zsh", description: "Output zsh completion script" }, - { command: "clerk completion fish", description: "Output fish completion script" }, - { - command: "clerk completion powershell", - description: "Output PowerShell completion script", - }, - ]) - .addHelpText( - "after", - ` -Tutorial — enable completions for your shell: - - Bash: - $ eval "$(clerk completion bash)" # Current session only - $ clerk completion bash > /etc/bash_completion.d/clerk # Permanent (Linux) - $ echo 'eval "$(clerk completion bash)"' >> ~/.bashrc # Permanent (append) - - Zsh: - $ eval "$(clerk completion zsh)" # Current session only - $ mkdir -p ~/.zfunc && clerk completion zsh > ~/.zfunc/_clerk # Permanent - # Then add to ~/.zshrc: fpath=(~/.zfunc $fpath); autoload -Uz compinit && compinit - - Fish: - $ mkdir -p ~/.config/fish/completions - $ clerk completion fish > ~/.config/fish/completions/clerk.fish # Auto-discovered - - PowerShell: - $ clerk completion powershell | Out-String | Invoke-Expression # Current session - $ clerk completion powershell >> $PROFILE # Permanent`, - ) - .action(completion); - - program - .command("update") - .description("Update the Clerk CLI to the latest version") - .option("--channel ", "Release channel to update to (e.g. latest, canary)") - .option("-y, --yes", "Skip confirmation prompt") - .option("--all", "Update every clerk install found on PATH, not just the first") - .setExamples([ - { command: "clerk update", description: "Update to the latest stable release" }, - { - command: "clerk update --channel canary", - description: "Update to the latest canary release", - }, - { command: "clerk update --yes", description: "Update without confirmation prompt" }, - { command: "clerk update --all", description: "Update every clerk install on PATH" }, - ]) - .action(update); - - const deployCmd = program - .command("deploy") - .description("Deploy a Clerk application to production"); - deployCmd.command("run", { isDefault: true, hidden: true }).action(deploy); - deployCmd - .command("status") - .description("Show production deploy status (read-only)") - .option("--wait", "Wait for DNS, SSL, and email DNS verification with retries") - .action(deployStatus); - - registerExtras(program); + for (const register of registrants) { + register(program); + } return program; } diff --git a/packages/cli-core/src/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index 46617965..92ac731b 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -1,3 +1,4 @@ +import type { Command } from "@commander-js/extra-typings"; import { getAuthToken } from "../../lib/plapi.ts"; import { getBapiBaseUrl, getPlapiBaseUrl } from "../../lib/environment.ts"; import { normalizeBapiPath, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; @@ -197,3 +198,34 @@ function prettyPrintToStderr(text: string): void { log.raw(text); } } + +export function registerApi(program: Command): void { + program + .command("api") + .description("Make authenticated requests to the Clerk API") + .argument( + "[endpoint]", + "API endpoint path, 'ls' to list endpoints, or omit for interactive mode", + ) + .argument("[filter]", "Filter keyword (used with 'ls')") + .option("-X, --method ", "HTTP method (default: GET, or POST if body provided)") + .option("-d, --data ", "JSON request body") + .option("--file ", "Read request body from a file") + .option("--include", "Show response headers") + .option("--app ", "Application ID to target when resolving keys") + .option("--secret-key ", "Override the secret key") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--platform", "Use Platform API instead of Backend API") + .option("--dry-run", "Show the request without executing it") + .option("--yes", "Skip confirmation for mutating requests") + .setExamples([ + { command: "clerk api ls", description: "List all available endpoints" }, + { command: "clerk api ls users", description: 'List endpoints matching "users"' }, + { command: "clerk api /users", description: "GET /v1/users" }, + { + command: 'clerk api /users -d \'{"first_name":"Alice"}\'', + description: "POST with a JSON body", + }, + ]) + .action(api); +} diff --git a/packages/cli-core/src/commands/apps/index.ts b/packages/cli-core/src/commands/apps/index.ts index 7baf33e1..5b986422 100644 --- a/packages/cli-core/src/commands/apps/index.ts +++ b/packages/cli-core/src/commands/apps/index.ts @@ -1,4 +1,28 @@ +import type { Command } from "@commander-js/extra-typings"; import { list } from "./list.ts"; import { create } from "./create.ts"; -export const apps = { list, create }; +export function registerApps(program: Command): void { + const apps = program.command("apps").description("Manage your Clerk applications"); + + apps + .command("list") + .description("List your Clerk applications") + .option("--json", "Output as JSON") + .setExamples([ + { command: "clerk apps list", description: "List all applications" }, + { command: "clerk apps list --json", description: "Output as JSON" }, + ]) + .action(list); + + apps + .command("create") + .description("Create a new Clerk application") + .argument("", "Application name") + .option("--json", "Output as JSON") + .setExamples([ + { command: 'clerk apps create "My App"', description: "Create a new application" }, + { command: 'clerk apps create "My App" --json', description: "Output as JSON" }, + ]) + .action(create); +} diff --git a/packages/cli-core/src/commands/auth/index.ts b/packages/cli-core/src/commands/auth/index.ts new file mode 100644 index 00000000..1aaaeb1e --- /dev/null +++ b/packages/cli-core/src/commands/auth/index.ts @@ -0,0 +1,49 @@ +import type { Command } from "@commander-js/extra-typings"; +import { login } from "./login.ts"; +import { logout } from "./logout.ts"; + +export function registerAuth(program: Command): void { + const auth = program + .command("auth") + .description("Manage authentication") + .setExamples([ + { command: "clerk auth login", description: "Log in via browser (OAuth)" }, + { command: "clerk auth logout", description: "Remove stored credentials" }, + ]); + + auth + .command("login") + .aliases(["signup", "signin", "sign-in"]) + .description("Log in to your Clerk account") + .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") + .setExamples([ + { command: "clerk auth login", description: "Log in via browser (OAuth)" }, + { + command: "clerk auth login -y", + description: "Re-authenticate via OAuth without confirmation when already signed in", + }, + ]) + .action(async (opts) => { + await login(opts); + }); + + auth + .command("logout") + .aliases(["signout", "sign-out"]) + .description("Log out of your Clerk account") + .setExamples([{ command: "clerk auth logout", description: "Remove stored credentials" }]) + .action(logout); + + program + .command("login", { hidden: true }) + .description("Log in to your Clerk account") + .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") + .action(async (opts) => { + await login(opts); + }); + + program + .command("logout", { hidden: true }) + .description("Log out of your Clerk account") + .action(logout); +} diff --git a/packages/cli-core/src/commands/completion/index.ts b/packages/cli-core/src/commands/completion/index.ts index af5d7876..6ceceb67 100644 --- a/packages/cli-core/src/commands/completion/index.ts +++ b/packages/cli-core/src/commands/completion/index.ts @@ -1,3 +1,4 @@ +import { createArgument, type Command } from "@commander-js/extra-typings"; import { generate as generateBash } from "./shells/bash.ts"; import { generate as generateZsh } from "./shells/zsh.ts"; import { generate as generateFish } from "./shells/fish.ts"; @@ -63,3 +64,47 @@ Run 'clerk completion --help' for full setup instructions.`, process.stdout.write(GENERATORS[shell]("clerk")); printNextSteps(INSTALL_HINTS[shell]); } + +export function registerCompletion(program: Command): void { + program + .command("completion") + .description("Generate shell autocompletion script") + .addArgument( + createArgument("[shell]", `Shell type (${SUPPORTED_SHELLS.join(", ")})`).choices( + SUPPORTED_SHELLS, + ), + ) + .setExamples([ + { command: "clerk completion bash", description: "Output bash completion script" }, + { command: "clerk completion zsh", description: "Output zsh completion script" }, + { command: "clerk completion fish", description: "Output fish completion script" }, + { + command: "clerk completion powershell", + description: "Output PowerShell completion script", + }, + ]) + .addHelpText( + "after", + ` +Tutorial — enable completions for your shell: + + Bash: + $ eval "$(clerk completion bash)" # Current session only + $ clerk completion bash > /etc/bash_completion.d/clerk # Permanent (Linux) + $ echo 'eval "$(clerk completion bash)"' >> ~/.bashrc # Permanent (append) + + Zsh: + $ eval "$(clerk completion zsh)" # Current session only + $ mkdir -p ~/.zfunc && clerk completion zsh > ~/.zfunc/_clerk # Permanent + # Then add to ~/.zshrc: fpath=(~/.zfunc $fpath); autoload -Uz compinit && compinit + + Fish: + $ mkdir -p ~/.config/fish/completions + $ clerk completion fish > ~/.config/fish/completions/clerk.fish # Auto-discovered + + PowerShell: + $ clerk completion powershell | Out-String | Invoke-Expression # Current session + $ clerk completion powershell >> $PROFILE # Permanent`, + ) + .action(completion); +} diff --git a/packages/cli-core/src/commands/config/index.ts b/packages/cli-core/src/commands/config/index.ts new file mode 100644 index 00000000..a5f54d4a --- /dev/null +++ b/packages/cli-core/src/commands/config/index.ts @@ -0,0 +1,143 @@ +import type { Command } from "@commander-js/extra-typings"; +import { configPull } from "./pull.ts"; +import { configSchema } from "./schema.ts"; +import { configPatch, configPut } from "./push.ts"; + +export function registerConfig(program: Command): void { + const config = program + .command("config") + .description("Manage instance configuration") + .setExamples([ + { command: "clerk config pull", description: "Print dev config to stdout" }, + { command: "clerk config pull --instance prod", description: "Pull production config" }, + { command: "clerk config pull --output config.json", description: "Save config to a file" }, + { command: "clerk config schema", description: "Print full config schema" }, + { + command: "clerk config schema --keys auth_email session", + description: "Schema for specific top-level keys", + }, + { + command: "clerk config patch --file config.json", + description: "Apply partial update from file", + }, + { + command: 'clerk config patch --json \'{"key":"value"}\'', + description: "Inline JSON patch", + }, + { + command: "clerk config patch --file config.json --dry-run", + description: "Preview without applying", + }, + { + command: "clerk config put --file config.json", + description: "Replace entire config from file", + }, + { + command: "clerk config put --instance prod --file config.json", + description: "Replace production config", + }, + ]); + + config + .command("pull") + .description("Pull instance configuration from Clerk") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--output ", "Write config to a file instead of stdout") + .option( + "--keys ", + "Top-level config keys to retrieve, separated by spaces or commas (e.g. auth_email session)", + ) + .setExamples([ + { command: "clerk config pull", description: "Print dev config to stdout" }, + { command: "clerk config pull --instance prod", description: "Pull production config" }, + { command: "clerk config pull --output config.json", description: "Save config to a file" }, + ]) + .action(configPull); + + config + .command("schema") + .description("Pull instance config schema from Clerk") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--output ", "Write schema to a file instead of stdout") + .option( + "--keys ", + "Top-level schema sections to retrieve, separated by spaces or commas (e.g. auth_email session)", + ) + .setExamples([ + { command: "clerk config schema", description: "Print full config schema" }, + { + command: "clerk config schema --keys auth_email session", + description: "Schema for specific top-level keys", + }, + { command: "clerk config schema --output schema.json", description: "Save schema to a file" }, + ]) + .action(configSchema); + + config + .command("patch") + .description("Partially update instance configuration (PATCH)") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--file ", "Read config JSON from a file") + .option("--json ", "Pass config JSON inline") + .option("--dry-run", "Show what would be sent without making the API call") + .option("--yes", "Skip confirmation prompts") + .option( + "--destructive", + "Allow destructive changes that delete resources (e.g. session templates, custom OAuth providers) rather than just resetting config to defaults", + ) + .setExamples([ + { + command: "clerk config patch --file config.json", + description: "Apply partial update from file", + }, + { + command: 'clerk config patch --json \'{"key":"value"}\'', + description: "Inline JSON patch", + }, + { + command: "clerk config patch --file config.json --dry-run", + description: "Preview without applying", + }, + { + command: "clerk config patch --instance prod --file config.json", + description: "Patch production config", + }, + ]) + .action(configPatch); + + config + .command("put") + .description("Replace entire instance configuration (PUT)") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--file ", "Read config JSON from a file") + .option("--json ", "Pass config JSON inline") + .option("--dry-run", "Show what would be sent without making the API call") + .option("--yes", "Skip confirmation prompts") + .option( + "--destructive", + "Allow destructive changes that delete resources (e.g. session templates, custom OAuth providers) rather than just resetting config to defaults", + ) + .setExamples([ + { + command: "clerk config put --file config.json", + description: "Replace entire config from file", + }, + { + command: "clerk config put --file config.json --dry-run", + description: "Preview the replacement", + }, + { + command: "clerk config put --instance prod --file config.json", + description: "Replace production config", + }, + { + command: "clerk config put --file config.json --yes", + description: "Skip confirmation prompt", + }, + ]) + .action(configPut); +} diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index eb8fdbcb..cd0c0f21 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,3 +1,5 @@ +import type { Command } from "@commander-js/extra-typings"; +import { deployStatus } from "./status-command.ts"; import { isAgent } from "../../mode.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { bar, intro, outro, pausedOutro, withSpinner } from "../../lib/spinner.ts"; @@ -625,3 +627,15 @@ async function finishDeploy( log.info(nextStepsBlock(ctx.appId, productionInstanceId)); outro("Success"); } + +export function registerDeploy(program: Command): void { + const deployCmd = program + .command("deploy") + .description("Deploy a Clerk application to production"); + deployCmd.command("run", { isDefault: true, hidden: true }).action(deploy); + deployCmd + .command("status") + .description("Show production deploy status (read-only)") + .option("--wait", "Wait for DNS, SSL, and email DNS verification with retries") + .action(deployStatus); +} diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index c5d6ca25..99c0100e 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -1,3 +1,4 @@ +import type { Command } from "@commander-js/extra-typings"; import { isAgent, isHuman } from "../../mode.ts"; import { bold, green, red } from "../../lib/color.ts"; import { log } from "../../lib/log.ts"; @@ -138,3 +139,21 @@ export async function doctor(options: DoctorOptions = {}): Promise { } outro("All checks passing"); } + +export function registerDoctor(program: Command): void { + program + .command("doctor") + .description("Check your project's Clerk integration health") + .option("--verbose", "Show detailed output for each check") + .option("--json", "Output results as JSON") + .option("--spotlight", "Only show warnings and failures") + .option("--fix", "Attempt to auto-fix issues") + .setExamples([ + { command: "clerk doctor", description: "Run all health checks" }, + { command: "clerk doctor --verbose", description: "Show detailed output for each check" }, + { command: "clerk doctor --json", description: "Output results as machine-readable JSON" }, + { command: "clerk doctor --fix", description: "Auto-fix detected issues" }, + { command: "clerk doctor --spotlight", description: "Only show warnings and failures" }, + ]) + .action(doctor); +} diff --git a/packages/cli-core/src/commands/env/index.ts b/packages/cli-core/src/commands/env/index.ts new file mode 100644 index 00000000..c5e23683 --- /dev/null +++ b/packages/cli-core/src/commands/env/index.ts @@ -0,0 +1,28 @@ +import type { Command } from "@commander-js/extra-typings"; +import { pull } from "./pull.ts"; + +export function registerEnv(program: Command): void { + const env = program + .command("env") + .description("Manage environment variables") + .setExamples([ + { command: "clerk env pull", description: "Pull dev keys to .env.local" }, + { command: "clerk env pull --instance prod", description: "Pull production keys" }, + { command: "clerk env pull --file .env", description: "Write to a specific file" }, + { command: "clerk env pull --app app_abc123", description: "Target a specific application" }, + ]); + + env + .command("pull") + .description("Pull environment variables from Clerk to .env.local") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--file ", "Target env file (default: auto-detect)") + .setExamples([ + { command: "clerk env pull", description: "Pull dev keys to .env.local" }, + { command: "clerk env pull --instance prod", description: "Pull production keys" }, + { command: "clerk env pull --file .env", description: "Write to a specific file" }, + { command: "clerk env pull --app app_abc123", description: "Target a specific application" }, + ]) + .action(pull); +} diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 9391d40a..f81becc3 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -1,10 +1,11 @@ +import { createOption, type Command } from "@commander-js/extra-typings"; import { login } from "../auth/login.js"; import { link } from "../link/index.js"; import { pull } from "../env/pull.js"; import { isAgent } from "../../mode.js"; import { dim, bold } from "../../lib/color.js"; import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js"; -import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js"; +import { lookupFramework, FRAMEWORK_NAMES, type FrameworkInfo } from "../../lib/framework.js"; import { resolveProfile } from "../../lib/config.js"; import { deriveProjectName } from "../../lib/project-name.js"; import { log } from "../../lib/log.js"; @@ -39,7 +40,7 @@ import { type BootstrapResult, } from "./bootstrap.js"; import type { ProjectContext } from "./frameworks/types.js"; -import type { PackageManager } from "../../lib/package-manager.ts"; +import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; type InitOptions = { /** Framework to set up (skips auto-detection). */ @@ -423,3 +424,52 @@ async function scaffoldAndWrite( return { alreadySetUp: false }; } + +export function registerInit(program: Command): void { + program + .command("init") + .description("Initialize Clerk in your project") + .addOption( + createOption("--framework ", "Framework to set up (skips auto-detection)").choices( + FRAMEWORK_NAMES, + ), + ) + .addOption( + createOption( + "--pm ", + "Package manager to use (skips prompt/auto-detection)", + ).choices(PACKAGE_MANAGERS), + ) + .option("--name ", "Project name for --starter (skips prompt)") + .option("--app ", "Application ID to link (skips interactive picker)") + .option("--starter", "Create a new project from a starter template") + .option( + "--keyless", + "Use keyless development keys instead of logging in (only for keyless-capable frameworks)", + ) + .option("-y, --yes", "Skip confirmation prompts") + .option("--no-skills", "Skip the optional agent skills install prompt") + .setExamples([ + { command: "clerk init", description: "Auto-detect framework and set up Clerk" }, + { + command: "clerk init --framework next", + description: "Set up for Next.js (skips detection)", + }, + { + command: "clerk init --app app_123", + description: "Link to a specific Clerk application", + }, + { command: "clerk init --starter", description: "Create a new project with Clerk" }, + { + command: "clerk init --starter --framework next --pm bun", + description: "Bootstrap with Bun", + }, + { + command: "clerk init --starter --framework next --keyless", + description: "Bootstrap without logging in (uses temporary dev keys)", + }, + { command: "clerk init -y", description: "Skip all confirmation prompts" }, + { command: "clerk init --no-skills", description: "Skip the agent skills install prompt" }, + ]) + .action(init); +} diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 154b0536..0707242d 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -1,3 +1,4 @@ +import type { Command } from "@commander-js/extra-typings"; import { basename } from "node:path"; import { confirm } from "../../lib/prompts.ts"; import { isAgent } from "../../mode.ts"; @@ -196,3 +197,15 @@ async function resolveApp( message: `Select a Clerk application to link ${dim(`(repo: ${basename(displayPath)})`)}`, }); } + +export function registerLink(program: Command): void { + program + .command("link") + .description("Link this project to a Clerk application") + .option("--app ", "Application ID to link (skips interactive picker)") + .setExamples([ + { command: "clerk link", description: "Pick an app interactively" }, + { command: "clerk link --app app_abc123", description: "Link directly by application ID" }, + ]) + .action(link); +} diff --git a/packages/cli-core/src/commands/open/index.ts b/packages/cli-core/src/commands/open/index.ts index 25413abe..413d88a9 100644 --- a/packages/cli-core/src/commands/open/index.ts +++ b/packages/cli-core/src/commands/open/index.ts @@ -1,3 +1,4 @@ +import { createArgument, type Command } from "@commander-js/extra-typings"; import { resolveProfile } from "../../lib/config.ts"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { getDashboardUrl } from "../../lib/environment.ts"; @@ -102,3 +103,22 @@ export async function openDashboard( outro(); } + +export function registerOpen(program: Command): void { + const open = program.command("open").description("Open Clerk resources in your browser"); + + open + .command("dashboard", { isDefault: true }) + .description("Open the linked app's dashboard in your browser") + .addArgument( + createArgument("[subpath]", "Optional dashboard subpath (e.g. users, api-keys, settings)"), + ) + .option("--print", "Print the URL without opening the browser") + .setExamples([ + { command: "clerk open", description: "Open the linked app's dashboard" }, + { command: "clerk open users", description: "Open the users page" }, + { command: "clerk open api-keys", description: "Open the API keys page" }, + { command: "clerk open --print", description: "Print the dashboard URL" }, + ]) + .action((subpath, options) => openDashboard(subpath, options)); +} diff --git a/packages/cli-core/src/commands/skill/index.ts b/packages/cli-core/src/commands/skill/index.ts new file mode 100644 index 00000000..320a4e47 --- /dev/null +++ b/packages/cli-core/src/commands/skill/index.ts @@ -0,0 +1,35 @@ +import { createOption, type Command } from "@commander-js/extra-typings"; +import { PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; +import { skillInstall } from "./install.ts"; + +export function registerSkill(program: Command): void { + const skill = program + .command("skill") + .description("Manage the bundled Clerk CLI agent skill") + .setExamples([ + { command: "clerk skill install", description: "Install the clerk agent skill" }, + { + command: "clerk skill install -y", + description: "Install non-interactively (auto-detect agents, global scope)", + }, + ]); + + skill + .command("install") + .description("Install the bundled clerk agent skill") + .option("-y, --yes", "Skip prompts and run the `skills` CLI unattended") + .addOption( + createOption("--pm ", "Package manager hint for runner detection").choices( + PACKAGE_MANAGERS, + ), + ) + .setExamples([ + { command: "clerk skill install", description: "Install with an interactive runner picker" }, + { command: "clerk skill install -y", description: "Install unattended" }, + { + command: "clerk skill install --pm bun", + description: "Force bunx as the runner", + }, + ]) + .action(skillInstall); +} diff --git a/packages/cli-core/src/commands/switch-env/index.ts b/packages/cli-core/src/commands/switch-env/index.ts index cfb0b143..b9d881ed 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -7,6 +7,7 @@ * require re-authentication. */ +import type { Command } from "@commander-js/extra-typings"; import { setEnvironment } from "../../lib/config.ts"; import { getToken } from "../../lib/credential-store.ts"; import { @@ -85,3 +86,16 @@ export async function switchEnv(environmentArg: string | undefined): Promise", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--force-selection", "Force organization selection on login") + .option("--auto-create", "Auto-create an organization for new users") + .option("--max-members ", "Maximum members per organization") + .option("--domains", "Enable verified domains") + .option("--yes", "Skip confirmation prompts") + .option("--dry-run", "Show the patch that would be sent without applying it") + .setExamples([ + { command: "clerk enable orgs", description: "Enable organizations" }, + { + command: "clerk enable orgs --force-selection", + description: "Enable and force org selection", + }, + { + command: "clerk enable orgs --auto-create --max-members 10", + description: "Enable with auto-creation and member limit", + }, + { + command: "clerk enable orgs --dry-run", + description: "Preview the patch without applying it", + }, + ]) + .action(orgsEnable); + + enable + .command("billing") + .description("Enable billing for organizations and/or users") + .option( + "--for ", + "Billing targets (orgs and/or users), separated by spaces or commas (e.g. orgs users). Defaults to both when omitted.", + ) + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--yes", "Skip confirmation prompts") + .option("--dry-run", "Show the patch that would be sent without applying it") + .option("--no-skills", "Skip the optional `clerk-billing` agent skill install") + .setExamples([ + { + command: "clerk enable billing", + description: "Enable billing for organizations and users", + }, + { + command: "clerk enable billing --for orgs", + description: "Enable billing for organizations only", + }, + { + command: "clerk enable billing --for users", + description: "Enable billing for users only", + }, + { + command: "clerk enable billing --for orgs users", + description: "Enable billing for both targets", + }, + { + command: "clerk enable billing --no-skills", + description: "Enable without installing the agent skill", + }, + ]) + .action(billingEnable); + + const disable = program + .command("disable") + .description("Disable Clerk features on the linked instance") + .setExamples([ + { command: "clerk disable orgs", description: "Disable organizations" }, + { + command: "clerk disable billing --for orgs", + description: "Disable billing for organizations only (leaves organizations enabled)", + }, + { + command: "clerk disable billing", + description: "Disable billing for organizations and users", + }, + ]); + + disable + .command("orgs") + .alias("organizations") + .description("Disable organizations on the linked instance") + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--yes", "Skip confirmation prompts") + .option("--dry-run", "Show the patch that would be sent without applying it") + .setExamples([ + { command: "clerk disable orgs", description: "Disable organizations" }, + { + command: "clerk disable orgs --dry-run", + description: "Preview without applying", + }, + ]) + .action(orgsDisable); + + disable + .command("billing") + .description( + "Disable billing for organizations and/or users (does not disable organizations themselves)", + ) + .option( + "--for ", + "Billing targets (orgs and/or users), separated by spaces or commas (e.g. orgs users). Defaults to both when omitted.", + ) + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--yes", "Skip confirmation prompts") + .option("--dry-run", "Show the patch that would be sent without applying it") + .setExamples([ + { + command: "clerk disable billing", + description: "Disable billing for organizations and users", + }, + { + command: "clerk disable billing --for orgs", + description: "Disable billing for organizations only", + }, + { + command: "clerk disable billing --for users", + description: "Disable billing for users only", + }, + ]) + .action(billingDisable); +} diff --git a/packages/cli-core/src/commands/unlink/index.ts b/packages/cli-core/src/commands/unlink/index.ts index 521091ba..b00ad4e7 100644 --- a/packages/cli-core/src/commands/unlink/index.ts +++ b/packages/cli-core/src/commands/unlink/index.ts @@ -1,3 +1,4 @@ +import type { Command } from "@commander-js/extra-typings"; import { confirm } from "../../lib/prompts.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { resolveProfile, removeProfile } from "../../lib/config.ts"; @@ -46,3 +47,15 @@ export async function unlink(options: UnlinkOptions = {}): Promise { log.data(`\nUnlinked ${cyan(label)} from ${dim(displayPath)}`); outro(NEXT_STEPS.UNLINK); } + +export function registerUnlink(program: Command): void { + program + .command("unlink") + .description("Unlink this project from its Clerk application") + .option("--yes", "Skip confirmation prompt") + .setExamples([ + { command: "clerk unlink", description: "Unlink with confirmation prompt" }, + { command: "clerk unlink --yes", description: "Skip confirmation" }, + ]) + .action(unlink); +} diff --git a/packages/cli-core/src/commands/update/index.ts b/packages/cli-core/src/commands/update/index.ts index 5c7d49b3..dd3fe128 100644 --- a/packages/cli-core/src/commands/update/index.ts +++ b/packages/cli-core/src/commands/update/index.ts @@ -1,3 +1,4 @@ +import type { Command } from "@commander-js/extra-typings"; import { isAgent, isHuman } from "../../mode.ts"; import { green, cyan, yellow, dim } from "../../lib/color.ts"; import { CliError } from "../../lib/errors.ts"; @@ -429,3 +430,22 @@ export async function update(options: UpdateOptions): Promise { outro(anyFailed ? "Update completed with errors" : `Successfully updated to ${latest}`); } } + +export function registerUpdate(program: Command): void { + program + .command("update") + .description("Update the Clerk CLI to the latest version") + .option("--channel ", "Release channel to update to (e.g. latest, canary)") + .option("-y, --yes", "Skip confirmation prompt") + .option("--all", "Update every clerk install found on PATH, not just the first") + .setExamples([ + { command: "clerk update", description: "Update to the latest stable release" }, + { + command: "clerk update --channel canary", + description: "Update to the latest canary release", + }, + { command: "clerk update --yes", description: "Update without confirmation prompt" }, + { command: "clerk update --all", description: "Update every clerk install on PATH" }, + ]) + .action(update); +} diff --git a/packages/cli-core/src/commands/users/index.ts b/packages/cli-core/src/commands/users/index.ts index a2c3ca1a..c93eedb3 100644 --- a/packages/cli-core/src/commands/users/index.ts +++ b/packages/cli-core/src/commands/users/index.ts @@ -1,3 +1,5 @@ +import { createOption, createArgument, type Command } from "@commander-js/extra-typings"; +import { parseIntegerOption, collectOptionValues } from "../../lib/option-parsers.ts"; import { create } from "./create.ts"; import { list } from "./list.ts"; import { usersMenu } from "./menu.ts"; @@ -10,9 +12,173 @@ export { __resetUsersActionRegistryForTesting, } from "./registry.ts"; -export const users = { +const users = { create, list, menu: usersMenu, open, }; + +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}`, +]); + +export function registerUsers(program: Command): void { + const usersCommand = program + .command("users") + .description("Manage Clerk users") + .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" }, + { + command: "clerk users create --email alice@example.com --first-name Alice --yes", + description: "Create a user from curated flags", + }, + { + command: 'clerk users create -d \'{"email_address":["alice@example.com"]}\' --yes', + description: "Create a user from an inline BAPI request body", + }, + ]) + .action((_opts, cmd) => users.menu(cmd.optsWithGlobals() as Parameters[0])); + + usersCommand + .command("list") + .description("List users") + .option("--json", "Output as JSON") + .option("--limit ", "Maximum users to return (1-250, default 100)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 250 }), + ) + .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) => users.list(cmd.optsWithGlobals() as Parameters[0])); + + usersCommand + .command("create") + .description("Create a user") + .option("--json", "Output as JSON") + .option("--email ", "Email address") + .option("--phone ", "Phone number") + .option("--username ", "Username") + .option("--password ", "Password") + .option("--first-name ", "First name") + .option("--last-name ", "Last name") + .option("--external-id ", "External ID") + .option("-d, --data ", "Inline BAPI request body") + .option("--file ", "Read BAPI request body from a file") + .option("--dry-run", "Show the request without executing it") + .option("--yes", "Skip confirmation prompt") + .setExamples([ + { + command: "clerk users create --email alice@example.com --first-name Alice --yes", + description: "Create a user from curated flags", + }, + { + command: 'clerk users create -d \'{"email_address":["alice@example.com"]}\' --yes', + description: "Create a user from an inline BAPI request body", + }, + { + command: "clerk users create --file user.json --dry-run", + description: "Preview a request from a file without executing", + }, + ]) + .action((_opts, cmd) => + users.create(cmd.optsWithGlobals() as Parameters[0]), + ); + + usersCommand + .command("open") + .description("Open a user's dashboard page in your browser") + .addArgument(createArgument("[user-id]", "User ID to open. Omit to pick interactively.")) + .option("--print", "Print the URL without opening the browser") + .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 open", description: "Pick app (if not linked) and user, then open" }, + { + command: "clerk users open user_2x9k", + description: "Open a specific user (pick app if not linked)", + }, + { + command: "clerk users open user_2x9k --app app_123", + description: "Open a specific user against an explicit app", + }, + { + command: "clerk users open user_2x9k --print", + description: "Print the dashboard URL instead of opening", + }, + ]) + .action((userId, _opts, cmd) => + users.open({ + ...(cmd.optsWithGlobals() as Parameters[0]), + userId, + }), + ); +} diff --git a/packages/cli-core/src/commands/whoami/index.ts b/packages/cli-core/src/commands/whoami/index.ts index 5bcfa9ac..d145a31e 100644 --- a/packages/cli-core/src/commands/whoami/index.ts +++ b/packages/cli-core/src/commands/whoami/index.ts @@ -1,3 +1,4 @@ +import type { Command } from "@commander-js/extra-typings"; import { getValidToken } from "../../lib/credential-store.ts"; import { fetchUserInfo } from "../../lib/token-exchange.ts"; import { withSpinner } from "../../lib/spinner.ts"; @@ -63,3 +64,15 @@ export async function whoami(options: WhoamiOptions = {}) { } printNextSteps(resolved ? NEXT_STEPS.WHOAMI_LINKED : NEXT_STEPS.WHOAMI); } + +export function registerWhoami(program: Command): void { + program + .command("whoami") + .description("Show the current logged-in user and linked application") + .option("--json", "Output JSON") + .setExamples([ + { command: "clerk whoami", description: "Show your email and linked app" }, + { command: "clerk whoami --json", description: "Emit a structured payload on stdout" }, + ]) + .action((options) => whoami({ json: options.json })); +} diff --git a/packages/cli-core/src/lib/option-parsers.ts b/packages/cli-core/src/lib/option-parsers.ts new file mode 100644 index 00000000..7e364be8 --- /dev/null +++ b/packages/cli-core/src/lib/option-parsers.ts @@ -0,0 +1,25 @@ +import { throwUsageError } from "./errors.ts"; + +/** Commander option reducer: accumulate repeated `--flag value` occurrences into an array. */ +export function collectOptionValues(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +/** Parse and range-validate an integer option value, throwing a usage error on bad input. */ +export 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; +} From 59dee032c6d26c25603d9d2bd29497fce318a1cd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 2 Jun 2026 09:22:05 -0300 Subject: [PATCH 2/2] refactor(cli-program): use array iteration for command registration; add option-parsers tests Address PR review comments from wyattjoh: - Refactor command registration into a static `registrants` array iterated with a single loop, providing a common/standard registration pattern (comment on cli-program.ts) - Add unit tests for `option-parsers.ts` covering `collectOptionValues` and `parseIntegerOption` (comment on option-parsers.ts) - Also wire in `registerDeploy` for the deploy command added in main and remove the now-deleted `registerSkill` (cleanup after rebase) --- packages/cli-core/src/cli-program.ts | 2 - packages/cli-core/src/commands/skill/index.ts | 35 --------- .../cli-core/src/lib/option-parsers.test.ts | 72 +++++++++++++++++++ 3 files changed, 72 insertions(+), 37 deletions(-) delete mode 100644 packages/cli-core/src/commands/skill/index.ts create mode 100644 packages/cli-core/src/lib/option-parsers.test.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index c6fff179..19ee4098 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -17,7 +17,6 @@ import { registerApi } from "./commands/api/index.ts"; import { registerDoctor } from "./commands/doctor/index.ts"; import { registerSwitchEnv } from "./commands/switch-env/index.ts"; import { registerCompletion } from "./commands/completion/index.ts"; -import { registerSkill } from "./commands/skill/index.ts"; import { registerUpdate } from "./commands/update/index.ts"; import { registerDeploy } from "./commands/deploy/index.ts"; import { getEnvironment } from "./lib/config.ts"; @@ -62,7 +61,6 @@ const registrants: CommandRegistrant[] = [ registerDoctor, registerSwitchEnv, registerCompletion, - registerSkill, registerUpdate, registerDeploy, registerExtras, diff --git a/packages/cli-core/src/commands/skill/index.ts b/packages/cli-core/src/commands/skill/index.ts deleted file mode 100644 index 320a4e47..00000000 --- a/packages/cli-core/src/commands/skill/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createOption, type Command } from "@commander-js/extra-typings"; -import { PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; -import { skillInstall } from "./install.ts"; - -export function registerSkill(program: Command): void { - const skill = program - .command("skill") - .description("Manage the bundled Clerk CLI agent skill") - .setExamples([ - { command: "clerk skill install", description: "Install the clerk agent skill" }, - { - command: "clerk skill install -y", - description: "Install non-interactively (auto-detect agents, global scope)", - }, - ]); - - skill - .command("install") - .description("Install the bundled clerk agent skill") - .option("-y, --yes", "Skip prompts and run the `skills` CLI unattended") - .addOption( - createOption("--pm ", "Package manager hint for runner detection").choices( - PACKAGE_MANAGERS, - ), - ) - .setExamples([ - { command: "clerk skill install", description: "Install with an interactive runner picker" }, - { command: "clerk skill install -y", description: "Install unattended" }, - { - command: "clerk skill install --pm bun", - description: "Force bunx as the runner", - }, - ]) - .action(skillInstall); -} diff --git a/packages/cli-core/src/lib/option-parsers.test.ts b/packages/cli-core/src/lib/option-parsers.test.ts new file mode 100644 index 00000000..552482b0 --- /dev/null +++ b/packages/cli-core/src/lib/option-parsers.test.ts @@ -0,0 +1,72 @@ +import { test, expect, describe } from "bun:test"; +import { collectOptionValues, parseIntegerOption } from "./option-parsers.ts"; + +describe("collectOptionValues", () => { + test("returns the first value in an array when no previous array is supplied", () => { + expect(collectOptionValues("foo")).toEqual(["foo"]); + }); + + test("appends a new value to the existing array", () => { + expect(collectOptionValues("bar", ["foo"])).toEqual(["foo", "bar"]); + }); + + test("accumulates multiple values through successive calls", () => { + const first = collectOptionValues("a"); + const second = collectOptionValues("b", first); + const third = collectOptionValues("c", second); + expect(third).toEqual(["a", "b", "c"]); + }); + + test("does not mutate the previous array", () => { + const prev = ["x"]; + collectOptionValues("y", prev); + expect(prev).toEqual(["x"]); + }); +}); + +describe("parseIntegerOption", () => { + describe("valid inputs", () => { + test.each([ + { value: "0", min: 0, expected: 0 }, + { value: "1", min: 0, expected: 1 }, + { value: "100", min: 0, max: 200, expected: 100 }, + { value: "-5", min: -10, max: 0, expected: -5 }, + { value: "10", min: 10, max: 10, expected: 10 }, + ])( + "parses '$value' within range [$min, $max] as $expected", + ({ value, min, max, expected }) => { + expect(parseIntegerOption(value, "--flag", { min, max })).toBe(expected); + }, + ); + }); + + describe("non-integer inputs throw a usage error", () => { + test.each(["1.5", "abc", "", " ", "1e2", "0x1"])("throws for non-integer value %j", (value) => { + expect(() => parseIntegerOption(value, "--limit", { min: 0 })).toThrow( + /Invalid --limit value/, + ); + }); + }); + + describe("out-of-range inputs throw a usage error", () => { + test("throws when value is below min", () => { + expect(() => parseIntegerOption("-1", "--count", { min: 0 })).toThrow( + /Invalid --count value "-1". Must be >= 0/, + ); + }); + + test("throws when value is above max", () => { + expect(() => parseIntegerOption("101", "--count", { min: 0, max: 100 })).toThrow( + /Invalid --count value "101". Must be 0-100/, + ); + }); + + test("error message uses open-ended format when no max is provided", () => { + expect(() => parseIntegerOption("0", "--page", { min: 1 })).toThrow(/Must be >= 1/); + }); + + test("error message uses closed-range format when max is provided", () => { + expect(() => parseIntegerOption("0", "--page", { min: 1, max: 50 })).toThrow(/Must be 1-50/); + }); + }); +});