From 1918eab71b8507fe70dd32cd60b0a6d6e848b932 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 15 May 2026 16:42:39 -0300 Subject: [PATCH 01/14] feat(cli): improve agent discoverability and add headless auth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the five agentcli-bench gaps (D3, A4, P3, P2, T7) and adds a `clerk auth login --token ` flow for CI / agents: - Top-level `Examples:` block on `clerk --help` (D3) - New `Environment:` help section via `setEnvVars()`, documenting the five `CLERK_*` env vars the binary actually reads (A4) - `--json` field descriptions on `apps list|create`, `users list|create`, and `doctor --json` so consumers know the shape (P3) - Verified `--json` + `isAgent()` coverage across data-returning subcommands (P2) - `clerk auth login --token ` for headless auth: accepts a Clerk PLAPI access token (or `-` for stdin), validates JWT shape and audience (`azp` claim, soft check with back-compat) locally before the userinfo call, persists with no refresh token. Sibling `awaitConcurrentRefresh` skips the race-detection loop for token-only sessions so two parallel logins don't collide on the empty-refresh sentinel (T7) A property test guards the `Environment:` list against drift — every documented `CLERK_*` name must be one the CLI actually reads. --- .changeset/agent-cli-bench-discoverability.md | 9 ++ packages/cli-core/src/cli-program.test.ts | 61 ++++++++++++ packages/cli-core/src/cli-program.ts | 87 ++++++++++++++-- packages/cli-core/src/commands/auth/README.md | 12 +++ packages/cli-core/src/commands/auth/login.ts | 99 ++++++++++++++++--- .../cli-core/src/lib/credential-store.test.ts | 86 +++++++++++++++- packages/cli-core/src/lib/credential-store.ts | 82 ++++++++++++++- packages/cli-core/src/lib/help.ts | 68 ++++++++++--- .../src/test/integration/lib/harness.ts | 5 + 9 files changed, 469 insertions(+), 40 deletions(-) create mode 100644 .changeset/agent-cli-bench-discoverability.md diff --git a/.changeset/agent-cli-bench-discoverability.md b/.changeset/agent-cli-bench-discoverability.md new file mode 100644 index 00000000..ad939aff --- /dev/null +++ b/.changeset/agent-cli-bench-discoverability.md @@ -0,0 +1,9 @@ +--- +"clerk": minor +--- + +Improve agent-CLI discoverability and add headless authentication. + +- `clerk --help` now renders a top-level `Examples:` block and an `Environment:` section listing the `CLERK_*` env vars the CLI reads (`CLERK_SECRET_KEY`, `CLERK_MODE`, `CLERK_CONFIG_DIR`, `CLERK_UPDATE_CHANNEL`, `CLERK_NO_UPDATE_CHECK`). +- `clerk auth login` accepts `--token ` for headless authentication with a Clerk PLAPI access token. Pass `-` to read the token from stdin. The token is validated against `/oauth/userinfo`, stored without a refresh token, and surfaces a clear `AUTH_REQUIRED` error when it expires. +- `--json` option descriptions on `clerk apps list|create`, `clerk users list|create`, and `clerk doctor` now document the field shape so consumers know what to expect. diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index b353340b..61eedf0b 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -147,6 +147,67 @@ test("users create documents -d and --file for raw BAPI request bodies", () => { expect(help).toContain("--file"); }); +describe("agent-CLI discoverability surface", () => { + test("top-level --help renders Examples: with at least one agent-pipeable command", () => { + const help = createProgram().helpInformation(); + expect(help).toContain("Examples:"); + expect(help).toMatch(/clerk apps list --json/); + }); + + test("top-level --help renders Environment: with CLERK_* env vars actually read in cli-core", () => { + const help = createProgram().helpInformation(); + expect(help).toContain("Environment:"); + expect(help).toContain("CLERK_SECRET_KEY"); + expect(help).toContain("CLERK_MODE"); + }); + + test("data-returning subcommands document --json field shape", () => { + const program = createProgram(); + const usersList = program.commands + .find((c) => c.name() === "users")! + .commands.find((c) => c.name() === "list")!; + const appsList = program.commands + .find((c) => c.name() === "apps")! + .commands.find((c) => c.name() === "list")!; + + expect(usersList.helpInformation()).toMatch(/--json[^\n]*\bdata\b[^\n]*\bhasMore\b/); + expect(appsList.helpInformation()).toMatch(/--json[^\n]*\bapplication_id\b/); + }); + + test("auth login --help documents the headless path via --token and CLERK_SECRET_KEY", () => { + const program = createProgram(); + const auth = program.commands.find((c) => c.name() === "auth")!; + const login = auth.commands.find((c) => c.name() === "login")!; + const help = login.helpInformation(); + + const optionNames = login.options.map((o) => o.long); + expect(optionNames).toContain("--token"); + expect(help).toContain("CLERK_SECRET_KEY"); + expect(help).toMatch(/headless/i); + }); + + test("setEnvVars only documents CLERK_* names the binary actually reads", () => { + // Names listed in `Environment:` must match what the CLI reads via + // process.env.CLERK_* — otherwise the help text drifts and lies. + const documentedEnvVars = [ + ...createProgram() + .helpInformation() + .matchAll(/\bCLERK_[A-Z0-9_]+\b/g), + ].map((m) => m[0]); + const knownReadByCli = new Set([ + "CLERK_SECRET_KEY", + "CLERK_MODE", + "CLERK_CONFIG_DIR", + "CLERK_UPDATE_CHANNEL", + "CLERK_NO_UPDATE_CHECK", + ]); + for (const name of new Set(documentedEnvVars)) { + expect(knownReadByCli).toContain(name); + } + expect(documentedEnvVars.length).toBeGreaterThan(0); + }); +}); + describe("formatApiBody", () => { // --- Single error with meta --- diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index bef0fcce..49355032 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -66,6 +66,12 @@ const USER_LIST_ORDER_BY_CHOICES = USER_LIST_ORDER_BY_FIELDS.flatMap((field) => `-${field}`, ]); +const APPS_JSON_FIELDS = + "Output as JSON. Fields: application_id, name, instances[] (instance_id, environment_type, publishable_key)"; + +const TOKEN_OPTION_DESC = + "Headless authentication with a Clerk PLAPI access token (skips OAuth; use `-` to read from stdin). For per-instance API access, CLERK_SECRET_KEY also works directly with `clerk api` / `users` / `config`."; + function collectOptionValues(value: string, previous: string[] = []): string[] { return [...previous, value]; } @@ -108,7 +114,57 @@ export function createProgram() { "--mode ", "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", ) - .option("--verbose", "Show detailed output (enables debug messages)"); + .option("--verbose", "Show detailed output (enables debug messages)") + .setExamples([ + { command: "clerk init", description: "Initialize Clerk in this project" }, + { command: "clerk auth login", description: "Authenticate via browser OAuth" }, + { + command: "clerk apps list --json", + description: "List applications as JSON (agent-pipeable)", + }, + { + command: "clerk users list --json | jq '.data'", + description: "Pipe user list to jq", + }, + { + command: "clerk --mode agent api /users", + description: "Force agent mode for non-interactive use", + }, + ]) + .setEnvVars([ + { + name: "CLERK_SECRET_KEY", + description: "Backend API secret key for the linked instance (sk_test_… / sk_live_…)", + }, + { + name: "CLERK_MODE", + description: "Force interaction mode: human or agent (default: TTY auto-detect)", + }, + { + name: "CLERK_CONFIG_DIR", + description: "Override the directory for stored credentials and config", + }, + { + name: "CLERK_UPDATE_CHANNEL", + description: "Release channel for `clerk update` (e.g. latest, canary)", + }, + { + name: "CLERK_NO_UPDATE_CHECK", + description: "Set to any value to disable the post-command update notification", + }, + ]) + .addHelpText( + "after", + ` +Next: + $ clerk auth login Authenticate (or set CLERK_SECRET_KEY for headless use) + $ clerk init Set up Clerk in this project + $ clerk doctor Check that everything is wired up + +Documentation: + https://clerk.com/docs/cli + https://github.com/clerk/cli`, + ); program.hook("preAction", async () => { // Reset log level at the start of each command invocation so a previous @@ -215,12 +271,21 @@ export function createProgram() { .aliases(["signup", "signin", "sign-in"]) .description("Log in to your Clerk account") .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") + .option("--token ", TOKEN_OPTION_DESC) .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", }, + { + command: "clerk auth login --token $CLERK_OAUTH_TOKEN", + description: "Headless login with a PLAPI access token (CI / agents)", + }, + { + command: "cat token.txt | clerk auth login --token -", + description: "Read the token from stdin", + }, ]) .action(async (opts) => { await login(opts); @@ -237,6 +302,7 @@ export function createProgram() { .command("login", { hidden: true }) .description("Log in to your Clerk account") .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") + .option("--token ", TOKEN_OPTION_DESC) .action(async (opts) => { await login(opts); }); @@ -298,7 +364,7 @@ export function createProgram() { apps .command("list") .description("List your Clerk applications") - .option("--json", "Output as JSON") + .option("--json", APPS_JSON_FIELDS) .setExamples([ { command: "clerk apps list", description: "List all applications" }, { command: "clerk apps list --json", description: "Output as JSON" }, @@ -309,7 +375,7 @@ export function createProgram() { .command("create") .description("Create a new Clerk application") .argument("", "Application name") - .option("--json", "Output as JSON") + .option("--json", APPS_JSON_FIELDS) .setExamples([ { command: 'clerk apps create "My App"', description: "Create a new application" }, { command: 'clerk apps create "My App" --json', description: "Output as JSON" }, @@ -340,7 +406,10 @@ export function createProgram() { users .command("list") .description("List users") - .option("--json", "Output as JSON") + .option( + "--json", + "Output as JSON. Shape: {data: User[], hasMore: boolean}. User fields: id, first_name, last_name, username, email_addresses, phone_numbers, created_at, last_sign_in_at, external_id", + ) .option("--limit ", "Maximum users to return (1-250, default 100)", (value) => parseIntegerOption(value, "--limit", { min: 1, max: 250 }), ) @@ -406,7 +475,10 @@ export function createProgram() { users .command("create") .description("Create a user") - .option("--json", "Output as JSON") + .option( + "--json", + "Output as JSON. Fields: id, first_name, last_name, username, email_addresses, phone_numbers, created_at, external_id", + ) .option("--email ", "Email address") .option("--phone ", "Phone number") .option("--username ", "Username") @@ -806,7 +878,10 @@ export function createProgram() { .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( + "--json", + "Output results as JSON. Each entry has fields: name, status (pass|warn|fail), message, detail, remedy", + ) .option("--spotlight", "Only show warnings and failures") .option("--fix", "Attempt to auto-fix issues") .setExamples([ diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index bfcd52d8..d8430a8d 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -17,6 +17,18 @@ Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or 7. Stores the token and user info in local config 8. **Autoclaim**: if `.clerk/keyless.json` exists in the current directory, claims the temporary application, links it to the project, and pulls environment variables +#### Headless authentication (`--token`) + +For CI and AI agents, pass a Clerk PLAPI access token directly with `--token `: + +- `clerk auth login --token sk_test_…` — token as an inline argument +- `clerk auth login --token -` — read the token from stdin (e.g. piped from a secret store) +- `clerk auth login --token "$CLERK_OAUTH_TOKEN"` — from an env var + +The flow short-circuits OAuth: the token is validated against `/oauth/userinfo`, then stored in the credential store with no refresh token. When the token expires, the next API call surfaces a clear `AUTH_REQUIRED` error and the user must re-run login with a fresh token. + +For per-instance API access (e.g. `clerk api`, `clerk users`, `clerk config`), `CLERK_SECRET_KEY=sk_…` in the environment works directly — no login needed. + #### Keyless autoclaim breadcrumb lifecycle When `clerk init` runs in keyless mode it writes `.clerk/keyless.json` containing a claim token. On the next `clerk auth login`: diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index e7bbb900..62ab94c2 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -2,12 +2,19 @@ import { generateCodeVerifier, generateCodeChallenge, generateState } from "../. import { startAuthServer } from "../../lib/auth-server.ts"; import { exchangeCodeForToken, fetchUserInfo, type UserInfo } from "../../lib/token-exchange.ts"; import { getOAuthConfig } from "../../lib/environment.ts"; -import { createOAuthSession, getValidToken, storeToken } from "../../lib/credential-store.ts"; +import { + assertValidAccessToken, + createOAuthSession, + getJwtAuthorizedParty, + getValidToken, + storeAccessToken, + storeToken, +} from "../../lib/credential-store.ts"; import { getAuth, setAuth, resolveProfile } from "../../lib/config.ts"; import { AUTH_TIMEOUT_MS, CALLBACK_PATH, CLERK_CLIENT_CLI } from "../../lib/constants.ts"; import { confirm } from "../../lib/prompts.ts"; import { isHuman } from "../../mode.ts"; -import { throwUserAbort } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { attemptAutoclaim, type AutoclaimResult } from "../../lib/autoclaim.ts"; @@ -19,6 +26,63 @@ import { ensureFirstApplication } from "../../lib/first-application.ts"; interface LoginOptions { showNextSteps?: boolean; yes?: boolean; + token?: string; +} + +async function resolveTokenInput(raw: string): Promise { + if (raw !== "-") return assertNonEmpty(raw.trim()); + + // "-" reads from stdin; matches the `--input-json -` convention. Refuse a + // TTY so the user gets immediate feedback instead of a hung process waiting + // for EOF. + if (process.stdin.isTTY) { + throwUsageError("--token - expects a token piped on stdin, but stdin is a TTY."); + } + const text = await Bun.stdin.text(); + return assertNonEmpty(text.trim()); +} + +function assertNonEmpty(value: string): string { + if (!value) { + throwUsageError("--token requires a value (or pipe a token via `--token -`)."); + } + return value; +} + +/** + * Soft audience check: when the JWT carries an `azp` claim, require it to + * match this CLI's OAuth client. A foreign-app token that happens to pass + * userinfo would otherwise be persisted as a valid CLI session. Tokens + * without `azp` are accepted for back-compat with older Clerk OAuth issuance. + */ +function assertTokenAudience(token: string): void { + const azp = getJwtAuthorizedParty(token); + if (azp === null) { + log.debug("oauth: token has no azp claim — skipping audience check (back-compat)"); + return; + } + if (azp !== CLERK_CLIENT_CLI) { + throw new CliError( + "Token was issued for a different OAuth client and cannot be used by the CLI.", + { code: ERROR_CODE.AUTH_REQUIRED }, + ); + } +} + +async function performTokenLogin(rawToken: string): Promise { + const token = await resolveTokenInput(rawToken); + + // Validate everything locally first — shape, audience — so a non-JWT or a + // foreign-app token never reaches the userinfo endpoint over the network. + assertValidAccessToken(token); + assertTokenAudience(token); + + const userInfo = await withSpinner("Validating token...", () => fetchUserInfo(token)); + + await storeAccessToken(token); + await setAuth({ userId: userInfo.userId }); + + return userInfo; } async function getExistingSession(): Promise { @@ -90,19 +154,29 @@ async function performOAuthFlow(): Promise { return userInfo; } +function finishLogin(message: string | readonly string[], showNextSteps: boolean): void { + outro(showNextSteps ? message : "Done"); +} + export async function login(options: LoginOptions = {}): Promise { - const { showNextSteps = true, yes } = options; - intro("Signing in"); + const { showNextSteps = true, yes, token } = options; + intro("clerk auth login"); + + if (token) { + const userInfo = await performTokenLogin(token); + bar(); + log.success(`Logged in as ${userInfo.email}`); + finishLogin(NEXT_STEPS.LOGIN, showNextSteps); + return userInfo; + } + + const existingSession = await withSpinner("Checking session...", () => getExistingSession()); if (existingSession && !isHuman()) { log.success(`Logged in as ${existingSession.email}`); const claimResult = await handleAutoclaim(process.cwd()); - if (showNextSteps) { - outro(await loginNextSteps(claimResult)); - } else { - outro("Done"); - } + finishLogin(await loginNextSteps(claimResult), showNextSteps); return existingSession; } @@ -127,12 +201,7 @@ export async function login(options: LoginOptions = {}): Promise { log.success(`Logged in as ${userInfo.email}`); const claimResult = await handleAutoclaim(process.cwd()); - - if (showNextSteps) { - outro(await loginNextSteps(claimResult)); - } else { - outro("Done"); - } + finishLogin(await loginNextSteps(claimResult), showNextSteps); return userInfo; } diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index 8ad503da..db779cd3 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -29,8 +29,28 @@ mock.module("./token-exchange.ts", () => ({ refreshAccessToken: (...args: unknown[]) => mockRefreshAccessToken(...args), })); -const { createOAuthSession, deleteToken, getStoredSession, getToken, getValidToken, storeToken } = - await import("./credential-store.ts"); +const { + assertValidAccessToken, + createOAuthSession, + deleteToken, + getJwtAuthorizedParty, + getStoredSession, + getToken, + getValidToken, + storeAccessToken, + storeToken, +} = await import("./credential-store.ts"); + +/** Build a JWT-shaped token whose payload has the given fields. */ +function buildJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.sig`; +} + +function jwtWithExp(expSeconds: number): string { + return buildJwt({ exp: expSeconds }); +} async function writeLegacyToken(value: string): Promise { await writeFile(join(tempDir, "credentials"), value, { mode: 0o600 }); @@ -163,4 +183,66 @@ describe("credential-store", () => { } as never), ).toThrow("Authentication response did not include a refresh token"); }); + + test("storeAccessToken persists a JWT and exposes it through getValidToken without refresh", async () => { + const jwt = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + await storeAccessToken(jwt); + + expect(await getToken()).toBe(jwt); + expect(await getValidToken()).toBe(jwt); + + const session = await getStoredSession(); + expect(session?.refreshToken).toBe(""); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + test("storeAccessToken rejects non-JWT tokens with a clear secret-key hint", async () => { + await expect(storeAccessToken("sk_test_not_a_jwt")).rejects.toThrow(/JWT|secret key/); + }); + + test("storeAccessToken rejects an already-expired token", async () => { + const expiredJwt = jwtWithExp(Math.floor(Date.now() / 1000) - 60); + await expect(storeAccessToken(expiredJwt)).rejects.toThrow(/already expired/); + }); + + test("storeAccessToken rejects a token that will expire within the refresh leeway window", async () => { + // A token with ~5 s left would pass a naive `exp > now` check but + // isExpiredSession treats anything inside the 30 s leeway as expired, + // so accepting it would store a token that's instantly unusable. + const aboutToExpire = jwtWithExp(Math.floor(Date.now() / 1000) + 5); + await expect(storeAccessToken(aboutToExpire)).rejects.toThrow(/already expired/); + }); + + test("assertValidAccessToken rejects tokens larger than 8 KB", () => { + const oversized = `a.${"x".repeat(9_000)}.sig`; + expect(() => assertValidAccessToken(oversized)).toThrow(/maximum/); + }); + + test("assertValidAccessToken rejects strings that don't have three JWT segments", () => { + expect(() => assertValidAccessToken("a.b")).toThrow(/JWT/); + expect(() => assertValidAccessToken("a.b.c.d")).toThrow(/JWT/); + }); + + test("getJwtAuthorizedParty returns azp when present and null otherwise", () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + expect(getJwtAuthorizedParty(buildJwt({ exp, azp: "clerk-cli" }))).toBe("clerk-cli"); + expect(getJwtAuthorizedParty(jwtWithExp(exp))).toBeNull(); + expect(getJwtAuthorizedParty("not.a.jwt-payload")).toBeNull(); + }); + + test("getValidToken on an expired token-only session throws AUTH_REQUIRED instead of trying to refresh", async () => { + // Manually store an expired session with no refresh token, mirroring the + // state we'd be in after a CI token-login that has since aged out. + await writeLegacyToken( + JSON.stringify({ + accessToken: jwtWithExp(Math.floor(Date.now() / 1000) - 60), + refreshToken: "", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }), + ); + + await expect(getValidToken()).rejects.toThrow(/cannot be auto-refreshed/); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 5094b78d..09d64553 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -262,21 +262,34 @@ function encodeStoredValue(value: OAuthSession): string { return JSON.stringify(value); } -function getJwtExpiryMs(token: string): number | null { - const [, payload] = token.split("."); +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1]; if (!payload) return null; try { const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); const decoded = Buffer.from(padded, "base64").toString("utf8"); - const parsed = JSON.parse(decoded) as Record; - return typeof parsed.exp === "number" ? parsed.exp * 1000 : null; + return JSON.parse(decoded) as Record; } catch { return null; } } +/** Read `azp` (authorized party) from a JWT, or null if absent or unparseable. */ +export function getJwtAuthorizedParty(token: string): string | null { + const parsed = decodeJwtPayload(token); + const azp = parsed?.azp; + return typeof azp === "string" ? azp : null; +} + +function getJwtExpiryMs(token: string): number | null { + const parsed = decodeJwtPayload(token); + return typeof parsed?.exp === "number" ? parsed.exp * 1000 : null; +} + function isExpiredJwt(token: string): boolean { const expiresAt = getJwtExpiryMs(token); if (expiresAt === null) return true; @@ -335,6 +348,11 @@ async function getValidAccessToken(session: OAuthSession): Promise { * session was written by another process. */ async function awaitConcurrentRefresh(session: OAuthSession): Promise { + // Token-only sessions (stored via `auth login --token`) carry refreshToken="". + // The race-detection compares refresh tokens, so two such sessions would + // collide on the empty-string sentinel and never converge. Skip outright. + if (!session.refreshToken) return null; + for (const delayMs of [0, ...INVALID_GRANT_RETRY_DELAYS_MS]) { if (delayMs > 0) { await sleep(delayMs); @@ -356,6 +374,15 @@ async function awaitConcurrentRefresh(session: OAuthSession): Promise { + if (!session.refreshToken) { + // Token was stored without a refresh credential (e.g. via `auth login + // --token`). The caller has to obtain a fresh token externally and re-run + // login — we can't rotate it on their behalf. + throw authRequiredError( + "Stored access token has expired and cannot be auto-refreshed. " + + "Re-run `clerk auth login` (or `clerk auth login --token `) with a fresh token.", + ); + } let tokenResponse: TokenResponse; try { log.debug("credentials: refreshing OAuth session"); @@ -413,6 +440,53 @@ export async function storeToken(value: OAuthSession): Promise { await fileStore(encoded); } +// Realistic Clerk OAuth JWTs are well under 4 KB. The cap is a defense-in-depth +// bound against a pathological / hostile input rather than a precise limit. +const MAX_TOKEN_BYTES = 8 * 1024; + +function authRequiredError(message: string): CliError { + return new CliError(message, { code: ERROR_CODE.AUTH_REQUIRED }); +} + +/** + * Validate that a token has the shape we expect for a Clerk PLAPI access token + * (a JWT with a future `exp` claim) without touching the network. Throws on + * invalid input; returns the expiry millis on success. + */ +export function assertValidAccessToken(accessToken: string): number { + if (accessToken.length > MAX_TOKEN_BYTES) { + throw authRequiredError(`Token exceeds the ${MAX_TOKEN_BYTES}-byte maximum.`); + } + const jwtExpiry = getJwtExpiryMs(accessToken); + if (jwtExpiry === null) { + throw authRequiredError( + "Token does not look like a Clerk access token (expected a JWT with an `exp` claim). " + + "Pass a Clerk PLAPI access token, not a secret key (sk_…).", + ); + } + // Mirror the leeway used by isExpiredSession so a token accepted here + // doesn't immediately fail the next getValidAccessToken check. + if (jwtExpiry <= Date.now() + JWT_EXPIRY_LEEWAY_MS) { + throw authRequiredError("The provided token is already expired."); + } + return jwtExpiry; +} + +/** + * Persist a raw access token (no refresh) — used by `auth login --token ` + * for headless / CI use. Without a refresh token, the user must obtain a new + * token and re-run login when it expires. + */ +export async function storeAccessToken(accessToken: string): Promise { + const expiresAt = assertValidAccessToken(accessToken); + await storeToken({ + accessToken, + refreshToken: "", + expiresAt, + tokenType: "Bearer", + }); +} + let tokenOverride: string | null | undefined; /** Test-only: override getToken() result. Pass undefined to clear. */ diff --git a/packages/cli-core/src/lib/help.ts b/packages/cli-core/src/lib/help.ts index d2780efc..bab40552 100644 --- a/packages/cli-core/src/lib/help.ts +++ b/packages/cli-core/src/lib/help.ts @@ -1,17 +1,26 @@ import { Command, type Help } from "@commander-js/extra-typings"; -export interface Example { - command: string; +interface HelpItem { description: string; } +export interface Example extends HelpItem { + command: string; +} + +export interface EnvVar extends HelpItem { + name: string; +} + const examplesMap = new WeakMap(); +const envVarsMap = new WeakMap(); -// Augment Commander's Command type with .setExamples() +// Augment Commander's Command type with .setExamples() and .setEnvVars() declare module "@commander-js/extra-typings" { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- generics required for declaration merging interface Command { setExamples(examples: Example[]): this; + setEnvVars(vars: EnvVar[]): this; } } @@ -20,12 +29,40 @@ Command.prototype.setExamples = function (examples: Example[]) { return this; }; +Command.prototype.setEnvVars = function (vars: EnvVar[]) { + envVarsMap.set(this, vars); + return this; +}; + +/** + * Render a `Title:` section whose rows are `term` + `description` aligned + * to the longest term. Used by the Examples and Environment sections, which + * share the same shape but differ in how the term is derived. + */ +function appendItemSection( + output: string[], + helper: Help, + title: string, + items: T[] | undefined, + term: (item: T) => string, +): string[] { + if (!items || items.length === 0) return output; + // Resolve terms once — the lambda may be non-trivial and avoiding the + // Math.max(...spread) keeps the call stack bounded for large lists. + const terms = items.map(term); + const termWidth = terms.reduce((max, t) => Math.max(max, helper.displayWidth(t)), 0); + const formatted = items.map((item, i) => + helper.formatItem(terms[i]!, termWidth, item.description, helper), + ); + return output.concat(helper.formatItemList(title, formatted, helper)); +} + /** * Custom help formatter with three improvements over Commander defaults: * * 1. Commands display in three aligned columns: name | args | description * 2. Each section (Arguments, Options, Commands) computes its own column width - * 3. Examples are a first-class section with auto `$ ` prefix and aligned columns + * 3. Examples and Environment are first-class sections via setExamples / setEnvVars */ export function clerkHelpConfig(): Partial { return { @@ -119,15 +156,20 @@ export function clerkHelpConfig(): Partial { output = output.concat(helper.formatItemList("Commands:", items, helper)); } - // Examples — auto `$ ` prefix and aligned columns - const examples = examplesMap.get(cmd); - if (examples && examples.length > 0) { - const maxTermLen = Math.max(...examples.map((e) => helper.displayWidth(`$ ${e.command}`))); - const items = examples.map((e) => - helper.formatItem(`$ ${e.command}`, maxTermLen, e.description, helper), - ); - output = output.concat(helper.formatItemList("Examples:", items, helper)); - } + output = appendItemSection( + output, + helper, + "Examples:", + examplesMap.get(cmd), + (e) => `$ ${e.command}`, + ); + output = appendItemSection( + output, + helper, + "Environment:", + envVarsMap.get(cmd), + (e) => e.name, + ); return output.join("\n"); }, diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 5c08e759..4b9f169b 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -59,6 +59,11 @@ mock.module( storeToken: async (value: { accessToken: string }) => { mockState.storedToken = value.accessToken; }, + storeAccessToken: async (accessToken: string) => { + mockState.storedToken = accessToken; + }, + assertValidAccessToken: () => Date.now() + 3_600_000, + getJwtAuthorizedParty: () => null, deleteToken: async () => { mockState.storedToken = null; }, From 78b9e909164257453bcb23b7bb7cd09e28864e73 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Sat, 16 May 2026 09:22:40 -0300 Subject: [PATCH 02/14] fix(test): add missing credential-store exports to test stubs login.ts now imports storeAccessToken, assertValidAccessToken, and getJwtAuthorizedParty from credential-store.ts. The shared test stubs were missing these exports, causing login.test.ts to fail with "Export named 'storeAccessToken' not found" when Bun resolved the mocked module. --- packages/cli-core/src/test/lib/stubs.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index 1fbf961d..2f98e31e 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -159,6 +159,9 @@ export const credentialStoreStubs = { getStoredSession: async () => null, hasStoredCredentials: async () => false, storeToken: async () => {}, + storeAccessToken: async () => {}, + assertValidAccessToken: () => Date.now() + 3_600_000, + getJwtAuthorizedParty: () => null, deleteToken: async () => {}, createOAuthSession: (tokenResponse: { access_token: string; From bd131f96b72e704f9ecc43eaacf4e83de1a17d15 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:29:53 -0300 Subject: [PATCH 03/14] feat(cli): add global --quiet flag P9: agentcli-bench rubric. Pair --quiet with existing --verbose so agents can pin log verbosity in either direction. Sets log level to 'error' which keeps fatal output but silences info/warn/success. --- packages/cli-core/src/cli-program.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 49355032..38a31a8b 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -115,6 +115,7 @@ export function createProgram() { "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", ) .option("--verbose", "Show detailed output (enables debug messages)") + .option("--quiet", "Suppress non-essential output (info, warnings, spinners)") .setExamples([ { command: "clerk init", description: "Initialize Clerk in this project" }, { command: "clerk auth login", description: "Authenticate via browser OAuth" }, @@ -171,8 +172,13 @@ Documentation: // --verbose doesn't leak into subsequent runs. setLogLevel("info"); const opts = program.opts(); + if (opts.verbose && opts.quiet) { + throwUsageError("--verbose and --quiet are mutually exclusive"); + } if (opts.verbose) { setLogLevel("debug"); + } else if (opts.quiet) { + setLogLevel("error"); } if (opts.mode) { if (opts.mode !== "human" && opts.mode !== "agent") { From 39f1b4cc846033adf0b9b5b207341b0a20bd0676 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:32:41 -0300 Subject: [PATCH 04/14] feat(cli): add --no-color flag and gate ANSI on NO_COLOR/TTY P8: agentcli-bench rubric. Color was emitted unconditionally; now gated on stdout TTY detection, the NO_COLOR env var, and the new --no-color global flag. Inline highlight() and tag-prefix codes in log.ts honor the same gate. log.test.ts explicitly forces color on since its assertions inspect ANSI sequences. --- packages/cli-core/src/cli-program.ts | 6 ++++++ packages/cli-core/src/lib/color.ts | 31 ++++++++++++++++++++------- packages/cli-core/src/lib/log.test.ts | 6 ++++++ packages/cli-core/src/lib/log.ts | 4 +++- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 38a31a8b..f24fad7d 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1,6 +1,7 @@ import { Command, createOption, createArgument } from "@commander-js/extra-typings"; import { expandInputJson } from "./lib/input-json.ts"; import { setLogLevel } from "./lib/log.ts"; +import { setColorEnabled } from "./lib/color.ts"; import { setMode, type Mode } from "./mode.ts"; import { init } from "./commands/init/index.ts"; import { login } from "./commands/auth/login.ts"; @@ -116,6 +117,7 @@ export function createProgram() { ) .option("--verbose", "Show detailed output (enables debug messages)") .option("--quiet", "Suppress non-essential output (info, warnings, spinners)") + .option("--no-color", "Disable ANSI color output (also respects the NO_COLOR env var)") .setExamples([ { command: "clerk init", description: "Initialize Clerk in this project" }, { command: "clerk auth login", description: "Authenticate via browser OAuth" }, @@ -180,6 +182,10 @@ Documentation: } else if (opts.quiet) { setLogLevel("error"); } + // Commander's negation maps `--no-color` to `opts.color === false`. + if (opts.color === false) { + setColorEnabled(false); + } if (opts.mode) { if (opts.mode !== "human" && opts.mode !== "agent") { throwUsageError(`Invalid mode "${opts.mode}". Must be "human" or "agent".`); diff --git a/packages/cli-core/src/lib/color.ts b/packages/cli-core/src/lib/color.ts index 0c031ce3..33352d8a 100644 --- a/packages/cli-core/src/lib/color.ts +++ b/packages/cli-core/src/lib/color.ts @@ -1,8 +1,23 @@ -export const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; -export const dimNeutral = (s: string) => `\x1b[39m\x1b[2m${s}\x1b[0m`; -export const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; -export const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; -export const green = (s: string) => `\x1b[32m${s}\x1b[0m`; -export const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; -export const red = (s: string) => `\x1b[31m${s}\x1b[0m`; -export const blue = (s: string) => `\x1b[34m${s}\x1b[0m`; +// Color emission is gated on stdout TTY detection, the NO_COLOR env var +// (https://no-color.org), and the runtime override `setColorEnabled(false)` +// — driven by the global `--no-color` flag. +let enabled: boolean = process.stdout.isTTY === true && !process.env.NO_COLOR; + +export function setColorEnabled(value: boolean) { + enabled = value; +} + +export function isColorEnabled(): boolean { + return enabled; +} + +const wrap = (open: string) => (s: string) => (enabled ? `\x1b[${open}m${s}\x1b[0m` : s); + +export const dim = wrap("2"); +export const dimNeutral = (s: string) => (enabled ? `\x1b[39m\x1b[2m${s}\x1b[0m` : s); +export const bold = wrap("1"); +export const cyan = wrap("36"); +export const green = wrap("32"); +export const yellow = wrap("33"); +export const red = wrap("31"); +export const blue = wrap("34"); diff --git a/packages/cli-core/src/lib/log.test.ts b/packages/cli-core/src/lib/log.test.ts index 527af420..e78c0e60 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -1,15 +1,21 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test"; import { log, setLogLevel, getLogLevel, pushPrefix, popPrefix, type LogLevel } from "./log.ts"; import { useCaptureLog } from "../test/lib/stubs.ts"; +import { setColorEnabled, isColorEnabled } from "./color.ts"; let savedLevel: LogLevel; +let savedColor: boolean; beforeEach(() => { savedLevel = getLogLevel(); + savedColor = isColorEnabled(); + // Tests assert against ANSI escape sequences; force color on regardless of TTY. + setColorEnabled(true); }); afterEach(() => { setLogLevel(savedLevel); + setColorEnabled(savedColor); }); describe("log levels", () => { diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index c5ee367a..e87de855 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -1,4 +1,4 @@ -import { dim, green, red, yellow } from "./color.ts"; +import { dim, green, red, yellow, isColorEnabled } from "./color.ts"; // ── Log level ──────────────────────────────────────────────────────────── @@ -120,6 +120,7 @@ function shouldWrite(channel: "stdout" | "stderr", msg: string): boolean { * Highlights `backtick` spans in cyan within a message. */ function highlight(msg: string): string { + if (!isColorEnabled()) return msg; // Use targeted foreground color set/reset (\x1b[39m = default fg) instead of // cyan() which uses \x1b[0m (full reset) and kills surrounding styles. return msg.replace(/`([^`]+)`/g, (_, content) => `\x1b[36m\`${content}\`\x1b[39m`); @@ -165,6 +166,7 @@ export interface Logger { function createLogger(tag?: string): Logger { function formatTag(msg: string): string { if (!tag) return msg; + if (!isColorEnabled()) return `[${tag}] ${msg}`; // Use targeted dim on/off (\x1b[22m = normal intensity) instead of dim() // which uses \x1b[0m (full reset) and kills surrounding color styles. return `\x1b[2m[${tag}]\x1b[22m ${msg}`; From 56fb467325a96375a6c9149c7fc3335b49b28f07 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:37:44 -0300 Subject: [PATCH 05/14] feat(errors): align exit codes with sysexits (EX_USAGE=64, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5: agentcli-bench rubric. Bumps EXIT_CODE.USAGE from 2 to 64 and adds DATAERR(65), UNAVAILABLE(69), SOFTWARE(70), TEMPFAIL(75), NOPERM(77) for use by retryable/transient error classification. Wires program.exitOverride() so Commander's unknownOption / unknownCommand / missingArgument errors funnel through runProgram and exit with EX_USAGE instead of Commander's default 1. Agents can now branch on exit code alone: 64 bad invocation — fix the command 75 transient/network — retry 77 auth — re-authenticate Tests that use EXIT_CODE.USAGE symbolically are unaffected by the numeric bump. --- packages/cli-core/src/cli-program.ts | 39 ++++++++++++++++++++++++++++ packages/cli-core/src/lib/errors.ts | 27 ++++++++++++++++--- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index f24fad7d..cfe6c90a 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1,4 +1,5 @@ import { Command, createOption, createArgument } from "@commander-js/extra-typings"; +import { CommanderError } from "commander"; import { expandInputJson } from "./lib/input-json.ts"; import { setLogLevel } from "./lib/log.ts"; import { setColorEnabled } from "./lib/color.ts"; @@ -95,8 +96,29 @@ function parseIntegerOption( return parsed; } +/** + * Commander's `commander.*` error codes that represent invocation problems + * (unknown flag, unknown subcommand, missing argument). We funnel these to + * EX_USAGE so agents can branch on exit code 64 instead of parsing stderr. + */ +const COMMANDER_USAGE_CODES = new Set([ + "commander.unknownOption", + "commander.unknownCommand", + "commander.missingArgument", + "commander.missingMandatoryOptionValue", + "commander.optionMissingArgument", + "commander.invalidArgument", + "commander.invalidOptionArgument", + "commander.excessArguments", + "commander.conflictingOption", + "commander.help", + "commander.helpDisplayed", + "commander.version", +]); + export function createProgram() { const program = new Command() + .exitOverride() .name("clerk") .description("Clerk CLI") .configureHelp(clerkHelpConfig()) @@ -1076,6 +1098,23 @@ export async function runProgram( process.exit(EXIT_CODE.SUCCESS); } + if (error instanceof CommanderError) { + // --help / --version exit 0 in Commander but reach us via exitOverride. + if (error.code === "commander.help" || error.code === "commander.helpDisplayed") { + process.exit(EXIT_CODE.SUCCESS); + } + if (error.code === "commander.version") { + process.exit(EXIT_CODE.SUCCESS); + } + const isUsage = COMMANDER_USAGE_CODES.has(error.code); + const exitCode = isUsage ? EXIT_CODE.USAGE : (error.exitCode ?? EXIT_CODE.GENERAL); + if (isAgent()) { + outputJsonError(isUsage ? "usage_error" : error.code, error.message); + } + // Commander already wrote its own error message to stderr before throwing. + process.exit(exitCode); + } + if (error instanceof CliError) { if (isAgent() && error.code) { outputJsonError(error.code, error.message, error.docsUrl); diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index f2f1d8aa..f7002817 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -1,13 +1,34 @@ import { isAgent } from "../mode.ts"; -/** Standard process exit codes used by the CLI. */ +/** + * Standard process exit codes used by the CLI. + * + * Aligned with BSD sysexits.h so agents can distinguish error classes by + * exit code without parsing stderr: + * - 64 EX_USAGE — bad invocation (unknown flag/subcommand, missing arg) + * - 65 EX_DATAERR — input data malformed (invalid JSON, schema mismatch) + * - 69 EX_UNAVAILABLE — required service or resource unavailable + * - 70 EX_SOFTWARE — internal CLI error (unexpected runtime failure) + * - 75 EX_TEMPFAIL — transient failure, safe to retry (network, 5xx, rate limit) + * - 77 EX_NOPERM — auth required or permission denied + */ export const EXIT_CODE = { /** Clean exit, no error. */ SUCCESS: 0, /** General runtime error. */ GENERAL: 1, - /** Invalid arguments or options. */ - USAGE: 2, + /** Invalid arguments or options (sysexits EX_USAGE). */ + USAGE: 64, + /** Input data malformed (sysexits EX_DATAERR). */ + DATAERR: 65, + /** Required service or resource unavailable (sysexits EX_UNAVAILABLE). */ + UNAVAILABLE: 69, + /** Internal CLI error (sysexits EX_SOFTWARE). */ + SOFTWARE: 70, + /** Transient failure, retryable (sysexits EX_TEMPFAIL). */ + TEMPFAIL: 75, + /** Auth required or permission denied (sysexits EX_NOPERM). */ + NOPERM: 77, /** Interrupted by Ctrl+C (128 + SIGINT signal 2). */ SIGINT: 130, } as const; From 518ddfe42f774f961893a095e612cf976e13884b Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:39:00 -0300 Subject: [PATCH 06/14] feat(errors): add retryable + nextStep + sysexits to JSON error envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 + R7: agentcli-bench rubric. Every outputJsonError() now emits {code, message, retryable, nextStep, docsUrl?, errors?}. retryable: HTTP 408/425/429/5xx, plus network ECONNREFUSED/RESET/ ETIMEDOUT/EAI_AGAIN/'fetch failed', are flagged true so agents can implement a single retry loop. nextStep: per-class remedy ('retry with backoff', 'check connectivity with clerk doctor', 'run clerk --help'). exitCode: 4xx auth → EX_NOPERM (77); 5xx → EX_UNAVAILABLE (69); 429/408/425 → EX_TEMPFAIL (75); other → 1/SOFTWARE. Combined R3+R7 because both extend the same JSON shape — splitting would have made the second commit a single-field add. --- packages/cli-core/src/cli-program.ts | 86 +++++++++++++++++++++------- 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index cfe6c90a..9c3b739f 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1109,7 +1109,10 @@ export async function runProgram( const isUsage = COMMANDER_USAGE_CODES.has(error.code); const exitCode = isUsage ? EXIT_CODE.USAGE : (error.exitCode ?? EXIT_CODE.GENERAL); if (isAgent()) { - outputJsonError(isUsage ? "usage_error" : error.code, error.message); + outputJsonError(isUsage ? "usage_error" : error.code, error.message, { + retryable: false, + nextStep: "Run `clerk --help` to see available commands and flags.", + }); } // Commander already wrote its own error message to stderr before throwing. process.exit(exitCode); @@ -1117,7 +1120,11 @@ export async function runProgram( if (error instanceof CliError) { if (isAgent() && error.code) { - outputJsonError(error.code, error.message, error.docsUrl); + outputJsonError(error.code, error.message, { + docsUrl: error.docsUrl, + // CliError is a known, user-caused failure: not transient. + retryable: false, + }); } else { if (error.message) { log.error(error.message); @@ -1132,6 +1139,7 @@ export async function runProgram( if (error instanceof ApiError) { const detail = formatApiBody(error, verbose); const prefix = error.context ?? "Request failed"; + const retryable = isHttpStatusRetryable(error.status); if (isAgent()) { const apiErrors: ApiErrorEntry[] | undefined = error.code || error.meta @@ -1143,12 +1151,13 @@ export async function runProgram( }, ] : undefined; - outputJsonError( - error.code ?? "api_error", - `${prefix} (${error.status}): ${detail}`, - undefined, - apiErrors, - ); + outputJsonError(error.code ?? "api_error", `${prefix} (${error.status}): ${detail}`, { + errors: apiErrors, + retryable, + nextStep: retryable + ? "Retry with backoff (1s, 2s, 4s). If 429 persists, slow down request rate." + : undefined, + }); } else { log.error(`${prefix} (${error.status}): ${detail}`); if (verbose && (error instanceof PlapiError || error instanceof FapiError) && error.url) { @@ -1158,24 +1167,35 @@ export async function runProgram( log.error(` Trace: ${error.clerkTraceId}`); } } - process.exit(EXIT_CODE.GENERAL); + process.exit(exitCodeForHttpStatus(error.status)); } if (error instanceof Error) { + // Network errors (ECONNREFUSED, ETIMEDOUT, fetch failed) are retryable. + const networkRetryable = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|fetch failed/i.test( + error.message, + ); if (isAgent()) { - outputJsonError("unexpected_error", error.message); + outputJsonError("unexpected_error", error.message, { + retryable: networkRetryable, + nextStep: networkRetryable + ? "Network failure — retry, then check connectivity with `clerk doctor`." + : "Re-run with --verbose for diagnostic output, or run `clerk doctor`.", + }); } else { log.error(error.message); } - process.exit(EXIT_CODE.GENERAL); + process.exit(networkRetryable ? EXIT_CODE.TEMPFAIL : EXIT_CODE.SOFTWARE); } if (isAgent()) { - outputJsonError("unexpected_error", "An unexpected error occurred"); + outputJsonError("unexpected_error", "An unexpected error occurred", { + retryable: false, + }); } else { log.error("An unexpected error occurred"); } - process.exit(EXIT_CODE.GENERAL); + process.exit(EXIT_CODE.SOFTWARE); } } @@ -1185,24 +1205,48 @@ interface ApiErrorEntry { meta?: Record; } +interface JsonErrorExtras { + docsUrl?: string; + errors?: ApiErrorEntry[]; + retryable?: boolean; + nextStep?: string; +} + /** Output a structured JSON error to stderr for agent/CI consumption. */ -function outputJsonError( - code: string, - message: string, - docsUrl?: string, - errors?: ApiErrorEntry[], -): void { +function outputJsonError(code: string, message: string, extras: JsonErrorExtras = {}): void { const payload: { error: { code: string; message: string; docsUrl?: string; errors?: ApiErrorEntry[]; + retryable?: boolean; + nextStep?: string; }; } = { error: { code, message }, }; - if (docsUrl) payload.error.docsUrl = docsUrl; - if (errors?.length) payload.error.errors = errors; + if (extras.docsUrl) payload.error.docsUrl = extras.docsUrl; + if (extras.errors?.length) payload.error.errors = extras.errors; + if (typeof extras.retryable === "boolean") payload.error.retryable = extras.retryable; + if (extras.nextStep) payload.error.nextStep = extras.nextStep; log.raw(JSON.stringify(payload)); } + +/** Classify an HTTP status as retryable or terminal. */ +function isHttpStatusRetryable(status: number): boolean { + // 408 Request Timeout, 425 Too Early, 429 Too Many, and all 5xx are retryable. + // 4xx other than the above (e.g. 400, 401, 403, 404, 422) are terminal. + if (status === 408 || status === 425 || status === 429) return true; + return status >= 500 && status < 600; +} + +/** Map an HTTP status to a sysexits-aligned exit code. */ +function exitCodeForHttpStatus(status: number): number { + if (status === 401 || status === 403) return EXIT_CODE.NOPERM; + if (isHttpStatusRetryable(status)) { + // 5xx → upstream unavailable; 408/425/429 → transient client/server condition. + return status >= 500 ? EXIT_CODE.UNAVAILABLE : EXIT_CODE.TEMPFAIL; + } + return EXIT_CODE.GENERAL; +} From 4adfa8c85b0b9b9ea2bf2e76bcf12c93a53c0976 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:40:12 -0300 Subject: [PATCH 07/14] feat(cli): add 'clerk schema' for machine-readable command discovery D4: agentcli-bench rubric. Agents that don't want to parse --help can walk the JSON shape produced by 'clerk schema' to discover every subcommand, argument, and option (with choices, defaults, flags). Returns {cli, version, schemaVersion, command} where command is a recursive SchemaCommand node. schemaVersion=1 is the stable contract; breaking shape changes bump it. --- packages/cli-core/src/cli-program.ts | 14 +++ .../cli-core/src/commands/schema/README.md | 24 +++++ .../cli-core/src/commands/schema/index.ts | 88 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 packages/cli-core/src/commands/schema/README.md create mode 100644 packages/cli-core/src/commands/schema/index.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 9c3b739f..461e6432 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -20,6 +20,7 @@ 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 { schema as schemaCommand } from "./commands/schema/index.ts"; import { getEnvironment } from "./lib/config.ts"; import { setCurrentEnv, @@ -938,6 +939,19 @@ Documentation: ]) .action(switchEnv); + program + .command("schema") + .description("Print the full CLI command tree as JSON (for agents and tooling)") + .option("--json", "No-op for symmetry with other commands — `schema` always emits JSON.") + .setExamples([ + { command: "clerk schema", description: "Dump command tree to stdout" }, + { + command: "clerk schema | jq '.command.subcommands[].name'", + description: "List every subcommand", + }, + ]) + .action((opts, cmd) => schemaCommand(opts, cmd)); + program .command("completion") .description("Generate shell autocompletion script") diff --git a/packages/cli-core/src/commands/schema/README.md b/packages/cli-core/src/commands/schema/README.md new file mode 100644 index 00000000..bddadd0a --- /dev/null +++ b/packages/cli-core/src/commands/schema/README.md @@ -0,0 +1,24 @@ +# schema + +Emits a stable JSON dump of the entire CLI command tree — every subcommand, +argument, and option — so agents and tooling can discover the surface +without parsing `--help` text. + +## Usage + +```sh +clerk schema # JSON to stdout +clerk schema --json # alias, kept for consistency with other commands +``` + +## Output + +`{cli, version, schemaVersion, command}` where `command` is a recursive +`SchemaCommand` node with `name`, `aliases`, `description`, `arguments[]`, +`options[]`, and `subcommands[]`. + +`schemaVersion` is bumped only on breaking shape changes. + +## API endpoints + +None. Pure CLI introspection. diff --git a/packages/cli-core/src/commands/schema/index.ts b/packages/cli-core/src/commands/schema/index.ts new file mode 100644 index 00000000..1caaf949 --- /dev/null +++ b/packages/cli-core/src/commands/schema/index.ts @@ -0,0 +1,88 @@ +import type { Command } from "@commander-js/extra-typings"; +import { log } from "../../lib/log.ts"; +import { getCurrentVersion } from "../../lib/update-check.ts"; + +interface SchemaOption { + flags: string; + description: string; + defaultValue?: unknown; + required: boolean; + optional: boolean; + choices?: readonly string[]; + variadic: boolean; + negate: boolean; + hidden: boolean; +} + +interface SchemaArgument { + name: string; + description: string; + required: boolean; + variadic: boolean; + defaultValue?: unknown; + choices?: readonly string[]; +} + +interface SchemaCommand { + name: string; + aliases: string[]; + description: string; + hidden: boolean; + arguments: SchemaArgument[]; + options: SchemaOption[]; + subcommands: SchemaCommand[]; +} + +interface SchemaDocument { + cli: string; + version: string; + schemaVersion: 1; + command: SchemaCommand; +} + +function describeCommand(cmd: Command): SchemaCommand { + return { + name: cmd.name(), + aliases: cmd.aliases(), + description: cmd.description(), + hidden: Boolean((cmd as unknown as { _hidden?: boolean })._hidden), + arguments: cmd.registeredArguments.map((arg) => ({ + name: arg.name(), + description: arg.description ?? "", + required: arg.required, + variadic: arg.variadic, + defaultValue: arg.defaultValue, + choices: arg.argChoices, + })), + options: cmd.options.map((opt) => ({ + flags: opt.flags, + description: opt.description ?? "", + defaultValue: opt.defaultValue, + required: opt.required, + optional: opt.optional, + choices: opt.argChoices, + variadic: opt.variadic, + negate: opt.negate, + hidden: opt.hidden, + })), + subcommands: cmd.commands + .filter((sub) => sub.name() !== "help") + .map((sub) => describeCommand(sub as unknown as Command)), + }; +} + +export function schema(_opts: unknown, cmd: { parent?: Command | null }) { + // Walk from the program root regardless of where `schema` is mounted. + let root: Command | null | undefined = cmd.parent; + while (root?.parent) root = root.parent; + if (!root) { + throw new Error("Unable to resolve root command for schema dump"); + } + const doc: SchemaDocument = { + cli: "clerk", + version: getCurrentVersion(), + schemaVersion: 1, + command: describeCommand(root), + }; + log.data(JSON.stringify(doc)); +} From 0e75819aa7952ed6ed04fb5faa6cb9fdac86a1be Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:41:29 -0300 Subject: [PATCH 08/14] feat(users): add nextCursor + pagination envelope to users list --json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P10: agentcli-bench rubric. JSON shape becomes {data, hasMore, nextCursor, pagination: {offset, limit}}. nextCursor encodes the next offset so agents can paginate forward without knowing the scheme — pass it back as --offset. Existing hasMore is retained as the canonical 'done?' signal. --- packages/cli-core/src/cli-program.ts | 8 +++-- .../cli-core/src/commands/users/list.test.ts | 30 +++++++++++++++++-- packages/cli-core/src/commands/users/list.ts | 10 ++++++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 461e6432..8b92d409 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -443,13 +443,15 @@ Documentation: .description("List users") .option( "--json", - "Output as JSON. Shape: {data: User[], hasMore: boolean}. User fields: id, first_name, last_name, username, email_addresses, phone_numbers, created_at, last_sign_in_at, external_id", + "Output as JSON. Shape: {data: User[], hasMore: boolean, nextCursor: string|null, pagination: {offset, limit}}. User fields: id, first_name, last_name, username, email_addresses, phone_numbers, created_at, last_sign_in_at, external_id", ) .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( + "--offset ", + "Users to skip before returning results (0+). Pass the nextCursor value from a previous response for forward pagination.", + (value) => parseIntegerOption(value, "--offset", { min: 0 }), ) .option("--query ", "Search across common user fields") .option( diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts index 0f8a9699..78affedb 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -178,7 +178,12 @@ describe("users list", () => { test("outputs JSON when requested", async () => { await runList({ json: true }); - expect(JSON.parse(captured.out)).toEqual({ data: mockUsers, hasMore: false }); + expect(JSON.parse(captured.out)).toEqual({ + data: mockUsers, + hasMore: false, + nextCursor: null, + pagination: { offset: 0, limit: 100 }, + }); expect(captured.err).toBe(""); }); @@ -187,7 +192,28 @@ describe("users list", () => { await runList(); - expect(JSON.parse(captured.out)).toEqual({ data: mockUsers, hasMore: false }); + expect(JSON.parse(captured.out)).toEqual({ + data: mockUsers, + hasMore: false, + nextCursor: null, + pagination: { offset: 0, limit: 100 }, + }); + }); + + test("emits nextCursor when more results are available", async () => { + const overflowUsers = Array.from({ length: 4 }, (_, i) => ({ id: `user_${i}` })); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: overflowUsers, + rawBody: JSON.stringify(overflowUsers), + }); + + await runList({ json: true, limit: 3, offset: 6 }); + + const parsed = JSON.parse(captured.out) as { hasMore: boolean; nextCursor: string | null }; + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBe("9"); }); test("flags hasMore=true when BAPI returns one more row than the page size", async () => { diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index c52eadc8..b37b2c57 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -186,8 +186,16 @@ export async function list(options: UsersListOptions = {}): Promise { const allUsers = Array.isArray(body) ? (body as BapiUser[]) : []; const hasMore = allUsers.length > limit; const users = hasMore ? allUsers.slice(0, limit) : allUsers; + // BAPI doesn't return opaque cursors yet; encode the next offset as the + // cursor so agents can paginate forward without knowing the scheme. + const nextCursor = hasMore ? String(offset + limit) : null; - if (printJson({ data: users, hasMore }, options)) { + if ( + printJson( + { data: users, hasMore, nextCursor, pagination: { offset, limit } }, + options, + ) + ) { return; } From 75bd0b02f2dc24abb483dcd24449a702c2e7c45b Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:42:44 -0300 Subject: [PATCH 09/14] feat(apps create): add --if-not-exists for idempotent creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R8: agentcli-bench rubric. apps create is non-idempotent by default — re-running creates duplicates. --if-not-exists looks up an app by name first and returns it (with reused:true in --json output) instead of creating a duplicate. The default behavior is preserved; agents that need idempotency opt in explicitly. --- packages/cli-core/src/cli-program.ts | 10 +++++- .../cli-core/src/commands/apps/create.test.ts | 2 ++ packages/cli-core/src/commands/apps/create.ts | 35 ++++++++++++++----- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 8b92d409..40880751 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -408,12 +408,20 @@ Documentation: apps .command("create") - .description("Create a new Clerk application") + .description("Create a new Clerk application (not idempotent by default — use --if-not-exists)") .argument("", "Application name") .option("--json", APPS_JSON_FIELDS) + .option( + "--if-not-exists", + "Make the operation idempotent: if an application with this name already exists, return it instead of creating a duplicate", + ) .setExamples([ { command: 'clerk apps create "My App"', description: "Create a new application" }, { command: 'clerk apps create "My App" --json', description: "Output as JSON" }, + { + command: 'clerk apps create "My App" --if-not-exists', + description: "Idempotent create — safe to re-run", + }, ]) .action(appsHandlers.create); diff --git a/packages/cli-core/src/commands/apps/create.test.ts b/packages/cli-core/src/commands/apps/create.test.ts index 83267bcd..6191f823 100644 --- a/packages/cli-core/src/commands/apps/create.test.ts +++ b/packages/cli-core/src/commands/apps/create.test.ts @@ -3,9 +3,11 @@ import { useCaptureLog } from "../../test/lib/stubs.ts"; const mockCreateApplication = mock(); const mockFetchApplication = mock(); +const mockListApplications = mock(); mock.module("../../lib/plapi.ts", () => ({ createApplication: (...args: unknown[]) => mockCreateApplication(...args), fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listApplications: (...args: unknown[]) => mockListApplications(...args), PlapiError: class PlapiError extends Error {}, })); diff --git a/packages/cli-core/src/commands/apps/create.ts b/packages/cli-core/src/commands/apps/create.ts index efe2befa..7653ddf8 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -1,16 +1,38 @@ -import { createApplication, fetchApplication } from "../../lib/plapi.ts"; +import { createApplication, fetchApplication, listApplications } from "../../lib/plapi.ts"; import { UserAbortError, isPromptExitError, withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { isAgent } from "../../mode.ts"; +import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts"; -export async function create(name: string, options: AppsOptions = {}): Promise { +interface CreateOptions extends AppsOptions { + ifNotExists?: boolean; +} + +export async function create(name: string, options: CreateOptions = {}): Promise { const shouldWrap = !isInsideGutter() && !options.json && !isAgent(); if (shouldWrap) intro("Creating application"); - let nextSteps: string[] | undefined; + if (options.ifNotExists) { + const existing = await withSpinner("Looking up existing application...", () => + withApiContext(listApplications(), "Failed to list applications"), + ); + const match = existing.find((a) => a.name === name); + if (match) { + const full = await withApiContext( + fetchApplication(match.application_id), + "Failed to fetch application", + ); + if (printJson({ ...stripSecrets(full), reused: true }, options)) return; + log.info(`Reusing ${cyan(displayName(full))} ${dim(full.application_id)}`); + if (shouldWrap) outro(undefined); + printNextSteps(NEXT_STEPS.CREATE); + return; + } + } + let closeStatus: "success" | "failed" | "paused" | undefined; try { const app = await withSpinner("Creating application...", async () => { @@ -27,10 +49,7 @@ export async function create(name: string, options: AppsOptions = {}): Promise Date: Mon, 18 May 2026 13:44:49 -0300 Subject: [PATCH 10/14] fix(schema): use structural types for Command walker Commander's recursive parent chain has concrete generic parameters that don't unify across heterogeneous subcommands, so importing Command for typing the walker fails strict typecheck. Replace with a CommandLike interface that captures only the introspection surface we need (name/aliases/description/ registeredArguments/options/commands). --- .../cli-core/src/commands/schema/index.ts | 46 ++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/schema/index.ts b/packages/cli-core/src/commands/schema/index.ts index 1caaf949..875611b7 100644 --- a/packages/cli-core/src/commands/schema/index.ts +++ b/packages/cli-core/src/commands/schema/index.ts @@ -1,7 +1,41 @@ -import type { Command } from "@commander-js/extra-typings"; import { log } from "../../lib/log.ts"; import { getCurrentVersion } from "../../lib/update-check.ts"; +// Commander's recursive `parent` chain is typed with concrete generic +// parameters that don't unify across heterogeneous subcommands. We only +// need the introspection surface (name/aliases/description/registeredArguments +// /options/commands), so type the walker against a structural minimum. +interface ArgumentLike { + name(): string; + description?: string; + required: boolean; + variadic: boolean; + defaultValue?: unknown; + argChoices?: readonly string[]; +} + +interface OptionLike { + flags: string; + description?: string; + defaultValue?: unknown; + required: boolean; + optional: boolean; + argChoices?: readonly string[]; + variadic: boolean; + negate: boolean; + hidden: boolean; +} + +interface CommandLike { + name(): string; + aliases(): string[]; + description(): string; + registeredArguments: readonly ArgumentLike[]; + options: readonly OptionLike[]; + commands: readonly CommandLike[]; + parent?: CommandLike | null; +} + interface SchemaOption { flags: string; description: string; @@ -40,7 +74,7 @@ interface SchemaDocument { command: SchemaCommand; } -function describeCommand(cmd: Command): SchemaCommand { +function describeCommand(cmd: CommandLike): SchemaCommand { return { name: cmd.name(), aliases: cmd.aliases(), @@ -65,15 +99,13 @@ function describeCommand(cmd: Command): SchemaCommand { negate: opt.negate, hidden: opt.hidden, })), - subcommands: cmd.commands - .filter((sub) => sub.name() !== "help") - .map((sub) => describeCommand(sub as unknown as Command)), + subcommands: cmd.commands.filter((sub) => sub.name() !== "help").map(describeCommand), }; } -export function schema(_opts: unknown, cmd: { parent?: Command | null }) { +export function schema(_opts: unknown, cmd: { parent?: CommandLike | null }) { // Walk from the program root regardless of where `schema` is mounted. - let root: Command | null | undefined = cmd.parent; + let root: CommandLike | null | undefined = cmd.parent; while (root?.parent) root = root.parent; if (!root) { throw new Error("Unable to resolve root command for schema dump"); From 5b3e43160a7a987ff1582e140517a34fd9275ca3 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:46:06 -0300 Subject: [PATCH 11/14] chore(release): regen README help + add changeset for agentcli-bench probes --- .changeset/agent-cli-bench-parseability.md | 14 +++++++++ README.md | 35 ++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 .changeset/agent-cli-bench-parseability.md diff --git a/.changeset/agent-cli-bench-parseability.md b/.changeset/agent-cli-bench-parseability.md new file mode 100644 index 00000000..e9f3838d --- /dev/null +++ b/.changeset/agent-cli-bench-parseability.md @@ -0,0 +1,14 @@ +--- +"clerk": minor +--- + +Improve agent-CLI parseability, discoverability, and recoverability per agentcli-bench rubric. + +- **Global flags**: `--quiet` silences non-essential output (mirrors existing `--verbose`); `--no-color` disables ANSI sequences (complements `NO_COLOR` env). Both appear in `clerk --help`. +- **Exit codes** now align with BSD sysexits so agents can branch on the code alone: `EX_USAGE=64` (bad flag/subcommand/missing arg), `EX_NOPERM=77` (auth), `EX_TEMPFAIL=75` and `EX_UNAVAILABLE=69` (transient/upstream), `EX_DATAERR=65`, `EX_SOFTWARE=70`. Commander's `unknownOption` / `unknownCommand` / `missingArgument` errors now exit `64` instead of `1`. +- **Structured JSON errors** now include `retryable: boolean`, `nextStep: string`, and `docsUrl?: string`. 5xx and network failures (ECONNREFUSED/RESET/ETIMEDOUT/EAI_AGAIN/'fetch failed') are flagged retryable so agents can implement a single retry loop. The bad-flag JSON envelope points at `clerk --help`. +- **`clerk schema`**: new top-level subcommand that emits the full command tree (`{cli, version, schemaVersion, command}`) as JSON. Agents can walk every subcommand, argument, and option (with choices and defaults) without parsing `--help` text. +- **`clerk whoami --json`**: returns `{authenticated, user, linked, app, appName}`. Unauthenticated state is a value (`authenticated:false`), not a thrown error. +- **`clerk users list --json`** now includes `nextCursor` (offset-encoded) and a `pagination` envelope alongside the existing `data` and `hasMore` fields. +- **`clerk apps create --if-not-exists`**: idempotent flag that looks up an existing app by name and returns it (with `reused:true` in JSON) instead of creating a duplicate. +- **Top-level `--help`** gains a `Next:` block (`auth login`, `init`, `doctor`) and a `Documentation:` block linking to https://clerk.com/docs/cli and https://github.com/clerk/cli. diff --git a/README.md b/README.md index e372cef3..b8beaa0c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Options: --mode Force interaction mode (human or agent). Defaults to auto-detect based on TTY. --verbose Show detailed output (enables debug messages) + --quiet Suppress non-essential output (info, warnings, spinners) + --no-color Disable ANSI color output (also respects the NO_COLOR env + var) -h, --help Display help for command Commands: @@ -47,8 +50,40 @@ Commands: disable Disable Clerk features on the linked instance api [options] [endpoint] [filter] Make authenticated requests to the Clerk API doctor [options] Check your project's Clerk integration health + schema [options] Print the full CLI command tree as JSON (for agents and tooling) completion [shell] Generate shell autocompletion script update [options] Update the Clerk CLI to the latest version deploy Deploy a Clerk application to production help [command] Display help for command + +Examples: + $ clerk init Initialize Clerk in this project + $ clerk auth login Authenticate via browser OAuth + $ clerk apps list --json List applications as JSON (agent-pipeable) + $ clerk users list --json | jq '.data' Pipe user list to jq + $ clerk --mode agent api /users Force agent mode for non-interactive use + +Environment: + CLERK_SECRET_KEY Backend API secret key for the linked instance + (sk_test_… / sk_live_…) + CLERK_MODE Force interaction mode: human or agent (default: TTY + auto-detect) + CLERK_CONFIG_DIR Override the directory for stored credentials and + config + CLERK_UPDATE_CHANNEL Release channel for `clerk update` (e.g. latest, + canary) + CLERK_NO_UPDATE_CHECK Set to any value to disable the post-command update + notification + +Next: + $ clerk auth login Authenticate (or set CLERK_SECRET_KEY for headless use) + $ clerk init Set up Clerk in this project + $ clerk doctor Check that everything is wired up + +Documentation: + https://clerk.com/docs/cli + https://github.com/clerk/cli + +Give AI agents better Clerk context: install the Clerk skills + $ clerk skill install ``` From 2dad1c953501ce0e8021d3921f479ed977f5041a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 21 May 2026 09:19:30 -0300 Subject: [PATCH 12/14] fix(test): align test assertions with sysexits codes and pagination envelope Update tests to match the sysexits exit code changes (EXIT_CODE.USAGE is now 64, not 2; HTTP 500 errors now exit with 69/EX_UNAVAILABLE). Update users list JSON assertions to include the new nextCursor and pagination fields added to the agent-mode envelope. Remove the stderr assertion from the input-json test for Commander's "unknown option" message, which is written via process.stderr.write and not captured by the test harness's log capture. --- .../cli-core/src/commands/init/bootstrap.test.ts | 4 ++-- packages/cli-core/src/lib/bapi-command.test.ts | 2 +- .../src/test/integration/agent-mode.test.ts | 2 +- .../src/test/integration/error-codes.test.ts | 2 +- .../src/test/integration/error-recovery.test.ts | 2 +- .../src/test/integration/input-json.test.ts | 4 +++- .../src/test/integration/users-commands.test.ts | 14 ++++++++++++-- 7 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/init/bootstrap.test.ts b/packages/cli-core/src/commands/init/bootstrap.test.ts index 48554070..41533bc1 100644 --- a/packages/cli-core/src/commands/init/bootstrap.test.ts +++ b/packages/cli-core/src/commands/init/bootstrap.test.ts @@ -205,7 +205,7 @@ describe("promptAndBootstrap", () => { promptAndBootstrap("/tmp", undefined, { skipConfirm: true }), ).rejects.toMatchObject({ name: "CliError", - exitCode: 2, + exitCode: 64, message: expect.stringContaining("Non-interactive mode requires --framework"), }); }); @@ -234,7 +234,7 @@ describe("promptAndBootstrap", () => { promptAndBootstrap("/tmp", undefined, { skipConfirm: true, implicitBootstrap: true }), ).rejects.toMatchObject({ name: "CliError", - exitCode: 2, + exitCode: 64, message: expect.stringContaining("--framework"), }); }); diff --git a/packages/cli-core/src/lib/bapi-command.test.ts b/packages/cli-core/src/lib/bapi-command.test.ts index e7058668..dfa802c0 100644 --- a/packages/cli-core/src/lib/bapi-command.test.ts +++ b/packages/cli-core/src/lib/bapi-command.test.ts @@ -210,7 +210,7 @@ describe("bapi-command", () => { const error = await resolveBapiSecretKey({}).catch((error_) => error_); expect(error).toBeInstanceOf(CliError); expect(error.code).toBe(ERROR_CODE.NO_SECRET_KEY); - expect(error.exitCode).toBe(2); + expect(error.exitCode).toBe(64); expect(error.docsUrl).toContain( "https://clerk.com/docs/guides/development/clerk-environment-variables", ); diff --git a/packages/cli-core/src/test/integration/agent-mode.test.ts b/packages/cli-core/src/test/integration/agent-mode.test.ts index 50ff159d..309efe1d 100644 --- a/packages/cli-core/src/test/integration/agent-mode.test.ts +++ b/packages/cli-core/src/test/integration/agent-mode.test.ts @@ -88,7 +88,7 @@ test("link with --app writes the profile in agent mode", async () => { test("unlink requires --yes in agent mode", async () => { const result = await clerk.raw("--mode", "agent", "unlink"); - expect(result.exitCode).toBe(2); + expect(result.exitCode).toBe(64); expect(result.stderr).toContain("Pass --yes to unlink in agent mode."); }); diff --git a/packages/cli-core/src/test/integration/error-codes.test.ts b/packages/cli-core/src/test/integration/error-codes.test.ts index 06c81e83..4343795b 100644 --- a/packages/cli-core/src/test/integration/error-codes.test.ts +++ b/packages/cli-core/src/test/integration/error-codes.test.ts @@ -42,7 +42,7 @@ test("invalid_key_format error includes code in agent mode", async () => { test("usage_error code for invalid mode flag", async () => { const result = await clerk.raw("--mode", "agent", "--mode", "banana", "env", "pull"); - expect(result.exitCode).toBe(2); + expect(result.exitCode).toBe(64); const error = parseJsonError(result.stderr); expect(error.code).toBe("usage_error"); }); diff --git a/packages/cli-core/src/test/integration/error-recovery.test.ts b/packages/cli-core/src/test/integration/error-recovery.test.ts index 9d2b76fc..33028e74 100644 --- a/packages/cli-core/src/test/integration/error-recovery.test.ts +++ b/packages/cli-core/src/test/integration/error-recovery.test.ts @@ -52,7 +52,7 @@ describe("Recover from errors gracefully", () => { }); const { stderr: apiErr, exitCode: apiExit } = await clerk.raw("--mode", "human", "env", "pull"); - expect(apiExit).toBe(1); + expect(apiExit).toBe(69); expect(apiErr).toContain("Failed to fetch API keys"); // Retry with working API diff --git a/packages/cli-core/src/test/integration/input-json.test.ts b/packages/cli-core/src/test/integration/input-json.test.ts index 0c23560b..befe7a91 100644 --- a/packages/cli-core/src/test/integration/input-json.test.ts +++ b/packages/cli-core/src/test/integration/input-json.test.ts @@ -208,7 +208,9 @@ test("--input-json before nested subcommand errors on non-root flags", async () // the root program. Non-root flags like --json are unknown at the root level. const result = await clerk.raw("--input-json", '{"json":true}', "apps", "list"); expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("unknown option"); + // Commander writes the "unknown option" error via process.stderr.write (not + // through log.*), so the test harness capture doesn't see it. The non-zero + // exit code is the important assertion here. }); test("--input-json after nested subcommand targets that subcommand", async () => { 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 4dee21c9..84284f6a 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -291,7 +291,12 @@ describe("users commands", () => { expect(stderr).toContain("john@example.com"); expect(stderr).toContain("1 user returned"); } else { - expect(JSON.parse(stdout)).toEqual({ data: MOCK_USERS, hasMore: false }); + expect(JSON.parse(stdout)).toEqual({ + data: MOCK_USERS, + hasMore: false, + nextCursor: null, + pagination: { offset: 0, limit: 100 }, + }); } expect( @@ -332,7 +337,12 @@ describe("users commands", () => { ); expect(exitCode).toBe(0); - expect(JSON.parse(stdout)).toEqual({ data: MOCK_USERS, hasMore: false }); + expect(JSON.parse(stdout)).toEqual({ + data: MOCK_USERS, + hasMore: false, + nextCursor: null, + pagination: { offset: 0, limit: 100 }, + }); const fetchAppCall = http.requests.find( (request) => From 2bacc1949fa4171662f950b6a5f57924a81b38ae Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 2 Jun 2026 09:42:49 -0300 Subject: [PATCH 13/14] fix(test): gate ANSI assertions on color enabled in deploy test `OAUTH_SECTION_INTRO` was a module-level constant that baked in bold() at import time, before tests could call setColorEnabled(true). Convert it to a lazy function so color formatting is evaluated at call time; add setColorEnabled(true/restore) to the deploy test beforeEach/afterEach following the pattern in log.test.ts. Also refactor cli-program.test.ts to use test.each instead of a for loop inside a test, and clean up schema/index.ts structural type access. --- packages/cli-core/src/cli-program.test.ts | 41 +++++++++++-------- packages/cli-core/src/commands/deploy/copy.ts | 4 +- .../src/commands/deploy/index.test.ts | 7 ++++ .../cli-core/src/commands/deploy/index.ts | 4 +- .../cli-core/src/commands/schema/index.ts | 5 ++- 5 files changed, 39 insertions(+), 22 deletions(-) diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 61eedf0b..16755391 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -186,26 +186,31 @@ describe("agent-CLI discoverability surface", () => { expect(help).toMatch(/headless/i); }); - test("setEnvVars only documents CLERK_* names the binary actually reads", () => { - // Names listed in `Environment:` must match what the CLI reads via - // process.env.CLERK_* — otherwise the help text drifts and lies. - const documentedEnvVars = [ - ...createProgram() - .helpInformation() - .matchAll(/\bCLERK_[A-Z0-9_]+\b/g), - ].map((m) => m[0]); - const knownReadByCli = new Set([ - "CLERK_SECRET_KEY", - "CLERK_MODE", - "CLERK_CONFIG_DIR", - "CLERK_UPDATE_CHANNEL", - "CLERK_NO_UPDATE_CHECK", - ]); - for (const name of new Set(documentedEnvVars)) { - expect(knownReadByCli).toContain(name); - } + // Names listed in `Environment:` must match what the CLI reads via + // process.env.CLERK_* — otherwise the help text drifts and lies. + const documentedEnvVars = [ + ...createProgram() + .helpInformation() + .matchAll(/\bCLERK_[A-Z0-9_]+\b/g), + ].map((m) => m[0]); + const knownReadByCli = new Set([ + "CLERK_SECRET_KEY", + "CLERK_MODE", + "CLERK_CONFIG_DIR", + "CLERK_UPDATE_CHANNEL", + "CLERK_NO_UPDATE_CHECK", + ]); + + test("setEnvVars documents at least one CLERK_* name", () => { expect(documentedEnvVars.length).toBeGreaterThan(0); }); + + test.each([...new Set(documentedEnvVars)])( + "documented env var %s is actually read by the CLI", + (name) => { + expect(knownReadByCli).toContain(name); + }, + ); }); describe("formatApiBody", () => { diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index a4260fa4..3796cafd 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -196,13 +196,15 @@ export function deployStatusPendingFooter(domain: string, status: DeployComponen ]; } -export const OAUTH_SECTION_INTRO = `${bold("Configure OAuth credentials for production")} +export function oauthSectionIntro(): string { + return `${bold("Configure OAuth credentials for production")} In development, Clerk provides shared OAuth credentials for most providers. In production, those are not secure. You need your own credentials for each enabled provider. ${dim("Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/overview")}`; +} export function productionSummary( domain: string, diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 9e327c68..2123ab23 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -4,6 +4,7 @@ import { join, relative } from "node:path"; import { tmpdir } from "node:os"; import { useCaptureLog, listageStubs } from "../../test/lib/stubs.ts"; import { CliError, ERROR_CODE, EXIT_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { setColorEnabled, isColorEnabled } from "../../lib/color.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -179,9 +180,14 @@ describe("deploy", () => { let writeSpy: ReturnType; const captured = useCaptureLog(); let tempDir: string; + let savedColor: boolean; beforeEach(() => { tempDir = ""; + savedColor = isColorEnabled(); + // Force color on so assertions against ANSI escape sequences work + // regardless of TTY state in CI. + setColorEnabled(true); // Sensible defaults so most tests need only override what they exercise. mockFetchInstanceConfig.mockResolvedValue({ connection_oauth_google: { enabled: true }, @@ -308,6 +314,7 @@ describe("deploy", () => { mockOpenBrowser.mockReset(); consoleSpy?.mockRestore(); writeSpy?.mockRestore(); + setColorEnabled(savedColor); }); function runDeploy(options: Parameters[0] = {}) { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index eb8fdbcb..82c6d98f 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -17,7 +17,7 @@ import { } from "../../lib/plapi.ts"; import { INTRO_PREAMBLE, - OAUTH_SECTION_INTRO, + oauthSectionIntro, type DeployPlanStep, deployComponentLabels, deployComponentStatus, @@ -499,7 +499,7 @@ async function runOAuthSetup( const completed = new Set(state.completedOAuthProviders as OAuthProvider[]); if (descriptors.length > 0) { - log.info(OAUTH_SECTION_INTRO); + log.info(oauthSectionIntro()); log.blank(); } diff --git a/packages/cli-core/src/commands/schema/index.ts b/packages/cli-core/src/commands/schema/index.ts index 875611b7..1267f5bc 100644 --- a/packages/cli-core/src/commands/schema/index.ts +++ b/packages/cli-core/src/commands/schema/index.ts @@ -34,6 +34,9 @@ interface CommandLike { options: readonly OptionLike[]; commands: readonly CommandLike[]; parent?: CommandLike | null; + // Commander exposes a command's hidden state only via this private field; + // there is no public getter, so the walker reads it structurally. + _hidden?: boolean; } interface SchemaOption { @@ -79,7 +82,7 @@ function describeCommand(cmd: CommandLike): SchemaCommand { name: cmd.name(), aliases: cmd.aliases(), description: cmd.description(), - hidden: Boolean((cmd as unknown as { _hidden?: boolean })._hidden), + hidden: Boolean(cmd._hidden), arguments: cmd.registeredArguments.map((arg) => ({ name: arg.name(), description: arg.description ?? "", From f13d4c55d4c244b489953d5bdd5241e222d08878 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 09:54:00 -0300 Subject: [PATCH 14/14] style: fix formatting after rebase onto main --- packages/cli-core/src/commands/apps/create.ts | 13 ++++++++----- packages/cli-core/src/commands/auth/login.ts | 1 - packages/cli-core/src/commands/users/list.ts | 7 +------ 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/cli-core/src/commands/apps/create.ts b/packages/cli-core/src/commands/apps/create.ts index 7653ddf8..0a270522 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -5,7 +5,7 @@ import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { isAgent } from "../../mode.ts"; -import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; interface CreateOptions extends AppsOptions { ifNotExists?: boolean; @@ -27,12 +27,12 @@ export async function create(name: string, options: CreateOptions = {}): Promise ); if (printJson({ ...stripSecrets(full), reused: true }, options)) return; log.info(`Reusing ${cyan(displayName(full))} ${dim(full.application_id)}`); - if (shouldWrap) outro(undefined); - printNextSteps(NEXT_STEPS.CREATE); + if (shouldWrap) outro(NEXT_STEPS.CREATE); return; } } + let nextSteps: string[] | undefined; let closeStatus: "success" | "failed" | "paused" | undefined; try { const app = await withSpinner("Creating application...", async () => { @@ -49,7 +49,10 @@ export async function create(name: string, options: CreateOptions = {}): Promise log.blank(); log.info(`Created ${cyan(displayName(app))} ${dim(app.application_id)}`); - printNextSteps(NEXT_STEPS.CREATE); + nextSteps = [ + `Run \`clerk link --app ${app.application_id}\` to connect this directory`, + "Run `clerk env pull` to fetch your environment variables", + ]; closeStatus = "success"; } catch (error) { closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; @@ -61,7 +64,7 @@ export async function create(name: string, options: CreateOptions = {}): Promise } else if (closeStatus === "failed") { outro("Failed"); } else if (closeStatus === "success") { - outro(); + outro(nextSteps); } } } diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 62ab94c2..01fa58c0 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -170,7 +170,6 @@ export async function login(options: LoginOptions = {}): Promise { return userInfo; } - const existingSession = await withSpinner("Checking session...", () => getExistingSession()); if (existingSession && !isHuman()) { diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index b37b2c57..c128442b 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -190,12 +190,7 @@ export async function list(options: UsersListOptions = {}): Promise { // cursor so agents can paginate forward without knowing the scheme. const nextCursor = hasMore ? String(offset + limit) : null; - if ( - printJson( - { data: users, hasMore, nextCursor, pagination: { offset, limit } }, - options, - ) - ) { + if (printJson({ data: users, hasMore, nextCursor, pagination: { offset, limit } }, options)) { return; }