From d2b819c1db783cac384e0cb0b23cefc2de648151 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 | 73 +++++++++++++- packages/cli-core/src/commands/auth/README.md | 12 +++ packages/cli-core/src/commands/auth/login.ts | 96 ++++++++++++++++--- .../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, 454 insertions(+), 38 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 3dc13520..cd2fc186 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -127,6 +127,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 a406fdcb..199890c4 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]; } @@ -105,6 +111,44 @@ export function createProgram() { "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", ) .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", () => isClerkSkillInstalled() ? "" @@ -218,12 +262,21 @@ Give AI agents better Clerk context: install the Clerk skills .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); @@ -240,6 +293,7 @@ Give AI agents better Clerk context: install the Clerk skills .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); }); @@ -297,7 +351,7 @@ Give AI agents better Clerk context: install the Clerk skills 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" }, @@ -308,7 +362,7 @@ Give AI agents better Clerk context: install the Clerk skills .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" }, @@ -339,7 +393,10 @@ Give AI agents better Clerk context: install the Clerk skills 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 }), ) @@ -405,7 +462,10 @@ Give AI agents better Clerk context: install the Clerk skills 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") @@ -805,7 +865,10 @@ Give AI agents better Clerk context: install the Clerk skills .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 c2c3eab6..01fa58c0 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,28 @@ 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; + 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 +200,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 ff4cb0c0..5b6570fa 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 { @@ -113,15 +150,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 a93c129b..e1e2e48d 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 29ef2503ca619a0d7d4797e8586000244d6f6645 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 93faff37..c7349b1b 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -65,6 +65,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 947a6bfb3e54d64daec54075aa2ada5141de46f4 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:23:46 -0300 Subject: [PATCH 03/14] docs(cli): add Documentation URLs to top-level --help D8: agentcli-bench rubric. Surface canonical docs links so agents discovering the CLI from --help can find usage references. --- packages/cli-core/src/cli-program.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 199890c4..e9a5bd47 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -149,6 +149,13 @@ export function createProgram() { description: "Set to any value to disable the post-command update notification", }, ]) + .addHelpText( + "after", + ` +Documentation: + https://clerk.com/docs/cli + https://github.com/clerk/cli`, + ) .addHelpText("after", () => isClerkSkillInstalled() ? "" From d21d0d84ed2a916394473d43d094e56673613502 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:29:53 -0300 Subject: [PATCH 04/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 e9a5bd47..15a25e98 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -111,6 +111,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" }, @@ -169,8 +170,13 @@ Give AI agents better Clerk context: install the Clerk skills // --verbose or --debug flag 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 ae955687f58ff68277e4dc35091207925c82fcef Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:32:41 -0300 Subject: [PATCH 05/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 15a25e98..ab93253a 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"; @@ -112,6 +113,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" }, @@ -178,6 +180,10 @@ Give AI agents better Clerk context: install the Clerk skills } 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 ddaafbeb..403ebb44 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -9,6 +9,7 @@ import { popPrefix, type LogLevel, } from "./log.ts"; +import { setColorEnabled, isColorEnabled } from "./color.ts"; function createCapture(): CapturedLogs { return { stdout: [], stderr: [] }; @@ -23,13 +24,18 @@ function deferred() { } 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("withCapturedLogs", () => { diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index 3530f132..9510d9cb 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { dim, green, red, yellow } from "./color.ts"; +import { dim, green, red, yellow, isColorEnabled } from "./color.ts"; // ── Log level ──────────────────────────────────────────────────────────── @@ -121,6 +121,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`); @@ -166,6 +167,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 ac0d38a9f85e1f85bbfebce6f4de66659a6f0aec Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:36:08 -0300 Subject: [PATCH 06/14] feat(whoami): add --json output R5: agentcli-bench rubric. Agents probing state need a structured whoami; the existing text output was email-only and 'not_logged_in' came back as a thrown error. The --json branch returns {authenticated, user, linked, app, appName} unconditionally so unauthenticated state is a value, not an error code. --- packages/cli-core/src/cli-program.ts | 9 ++++- .../cli-core/src/commands/whoami/index.ts | 39 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index ab93253a..8fa1a595 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -345,7 +345,14 @@ Give AI agents better Clerk context: install the Clerk skills program .command("whoami") .description("Show the current logged-in user") - .setExamples([{ command: "clerk whoami", description: "Show your email address" }]) + .option("--json", "Output as JSON. Fields: authenticated, user.email, linked, app, appName") + .setExamples([ + { command: "clerk whoami", description: "Show your email address" }, + { + command: "clerk whoami --json", + description: "Output current session as machine-readable JSON", + }, + ]) .action(whoami); const open = program.command("open").description("Open Clerk resources in your browser"); diff --git a/packages/cli-core/src/commands/whoami/index.ts b/packages/cli-core/src/commands/whoami/index.ts index 0e20b452..f31da401 100644 --- a/packages/cli-core/src/commands/whoami/index.ts +++ b/packages/cli-core/src/commands/whoami/index.ts @@ -6,7 +6,44 @@ import { AuthError } from "../../lib/errors.ts"; import { resolveProfile } from "../../lib/config.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; -export async function whoami() { +interface WhoamiOptions { + json?: boolean; +} + +export async function whoami(opts: WhoamiOptions = {}) { + if (opts.json) { + const token = await getValidToken(); + if (!token) { + // Authenticated=false is a state, not an error: agents read it from the + // envelope and exit 0 so `whoami --json` works as a probe. + log.data(JSON.stringify({ authenticated: false })); + return; + } + let userInfo; + try { + userInfo = await fetchUserInfo(token); + } catch { + log.data(JSON.stringify({ authenticated: false, reason: "session_expired" })); + return; + } + let resolved = null; + try { + resolved = await resolveProfile(process.cwd()); + } catch { + // best-effort + } + log.data( + JSON.stringify({ + authenticated: true, + user: { email: userInfo.email }, + linked: Boolean(resolved), + app: resolved?.profile.appId ?? null, + appName: resolved?.profile.appName ?? null, + }), + ); + return; + } + const token = await getValidToken(); if (!token) { throw new AuthError({ reason: "not_logged_in" }); From ade8f00abba9700a77febc1f26fd0d36907539d5 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:37:44 -0300 Subject: [PATCH 07/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 8fa1a595..b098a76b 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()) @@ -1115,6 +1137,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 ef577503..4b038ffa 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 7ccd6a75671aa3660ae60be36305dc5e2c1f1aee Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:39:00 -0300 Subject: [PATCH 08/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 b098a76b..a6665870 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1148,7 +1148,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); @@ -1156,7 +1159,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); @@ -1171,39 +1178,52 @@ export async function runProgram( if (error instanceof ApiError) { const detail = formatApiBody(error.body, verbose); const prefix = error.context ?? "Request failed"; + const retryable = isHttpStatusRetryable(error.status); if (isAgent()) { const apiCode = extractApiErrorCode(error.body); const apiErrors = extractApiErrors(error.body); - outputJsonError( - apiCode ?? "api_error", - `${prefix} (${error.status}): ${detail}`, - undefined, - apiErrors, - ); + outputJsonError(apiCode ?? "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) { log.error(` URL: ${error.url}`); } } - 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); } } @@ -1213,28 +1233,52 @@ 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; +} + /** Extract the error code from a Clerk API JSON response body, if present. */ function extractApiErrorCode(body: string): string | undefined { try { From 43ee0ca3340a42b3e0c885e2d49fb7162a3b7692 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:40:12 -0300 Subject: [PATCH 09/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 a6665870..10f4f93f 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, @@ -939,6 +940,19 @@ Give AI agents better Clerk context: install the Clerk skills ]) .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 90a47283ce4650e1b7ba1f9eafbc2334f3ed5801 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:41:29 -0300 Subject: [PATCH 10/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 | 13 ++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 10f4f93f..509fe036 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -444,13 +444,15 @@ Give AI agents better Clerk context: install the Clerk skills .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 a104e91a..db431706 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -176,7 +176,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(""); }); @@ -185,7 +190,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 d200bc17..c0634402 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -186,8 +186,17 @@ export async function list(options: UsersListOptions = {}): Promise { const allUsers = Array.isArray(body) ? (body as BapiUser[]) : []; const hasMore = allUsers.length > pageSize; const users = hasMore ? allUsers.slice(0, pageSize) : allUsers; - - if (printJson({ data: users, hasMore }, options)) return; + // 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 + pageSize) : null; + + if ( + printJson( + { data: users, hasMore, nextCursor, pagination: { offset, limit: pageSize } }, + options, + ) + ) + return; if (users.length === 0) { log.warn("No users found."); From 58f512d9beeddd3a29774b83f725ece53a32500c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:42:44 -0300 Subject: [PATCH 11/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 | 25 +++++++++++++++++-- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 509fe036..fdbeee7f 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -409,12 +409,20 @@ Give AI agents better Clerk context: install the Clerk skills 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 e0a943c2..c44ada93 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 { captureLog } 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 76acb6c7..6225611c 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -1,4 +1,4 @@ -import { createApplication, fetchApplication } from "../../lib/plapi.ts"; +import { createApplication, fetchApplication, listApplications } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; import { withSpinner } from "../../lib/spinner.ts"; @@ -6,7 +6,28 @@ import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; import { log } from "../../lib/log.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 { + 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)}`); + printNextSteps(NEXT_STEPS.CREATE); + return; + } + } + const app = await withSpinner("Creating application...", async () => { const created = await withApiContext(createApplication(name), "Failed to create application"); return withApiContext(fetchApplication(created.application_id), "Failed to fetch application"); From 08851dcc72a3be521c996828c6533145589c93d6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:43:33 -0300 Subject: [PATCH 12/14] docs(cli): add 'Next:' block to top-level --help R6: agentcli-bench rubric. Onboarding subcommands already print next-step hints (init/link/auth); top-level --help didn't. Adds a Next: block listing 'auth login', 'init', 'doctor' as the canonical first commands for new users (or agents discovering the CLI cold). --- packages/cli-core/src/cli-program.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index fdbeee7f..91851b0d 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -178,6 +178,11 @@ export function createProgram() { .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`, From 59e1ae4bde2637040d2d58e0628276a092916092 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:44:49 -0300 Subject: [PATCH 13/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 72b35441983b6a2683c099000e346f5802b80081 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 18 May 2026 13:46:06 -0300 Subject: [PATCH 14/14] chore(release): regen README help + add changeset for agentcli-bench probes --- .changeset/agent-cli-bench-parseability.md | 14 +++++++++ README.md | 34 +++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) 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 14a286de..0f05f76e 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: @@ -37,7 +40,7 @@ Commands: auth Manage authentication link [options] Link this project to a Clerk application unlink [options] Unlink this project from its Clerk application - whoami Show the current logged-in user + whoami [options] Show the current logged-in user open Open Clerk resources in your browser apps Manage your Clerk applications users [options] Manage Clerk users @@ -47,11 +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 skill Manage the bundled Clerk CLI agent skill update [options] Update the Clerk CLI to the latest version 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 ```