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/.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 ``` 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..91851b0d 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1,6 +1,8 @@ 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"; import { setMode, type Mode } from "./mode.ts"; import { init } from "./commands/init/index.ts"; import { login } from "./commands/auth/login.ts"; @@ -18,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, @@ -66,6 +69,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]; } @@ -88,8 +97,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()) @@ -105,6 +135,58 @@ 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)") + .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" }, + { + 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`, + ) .addHelpText("after", () => isClerkSkillInstalled() ? "" @@ -118,8 +200,17 @@ 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"); + } + // 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") { @@ -218,12 +309,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 +340,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); }); @@ -272,7 +373,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"); @@ -297,7 +405,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" }, @@ -306,12 +414,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", "Output as JSON") + .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); @@ -339,12 +455,17 @@ 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, 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( @@ -405,7 +526,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 +929,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([ @@ -828,6 +955,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") @@ -1026,9 +1166,33 @@ 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, { + 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); + } + 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); @@ -1043,39 +1207,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); } } @@ -1085,28 +1262,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 { 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"); 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/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..875611b7 --- /dev/null +++ b/packages/cli-core/src/commands/schema/index.ts @@ -0,0 +1,120 @@ +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; + 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: CommandLike): 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(describeCommand), + }; +} + +export function schema(_opts: unknown, cmd: { parent?: CommandLike | null }) { + // Walk from the program root regardless of where `schema` is mounted. + 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"); + } + const doc: SchemaDocument = { + cli: "clerk", + version: getCurrentVersion(), + schemaVersion: 1, + command: describeCommand(root), + }; + log.data(JSON.stringify(doc)); +} 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."); 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" }); 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/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/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; 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/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}`; 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; }, 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;