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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/agent-cli-bench-discoverability.md
Original file line number Diff line number Diff line change
@@ -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 <key>` 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.
61 changes: 61 additions & 0 deletions packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down
73 changes: 68 additions & 5 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down Expand Up @@ -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()
? ""
Expand Down Expand Up @@ -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 <key>", 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);
Expand All @@ -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 <key>", TOKEN_OPTION_DESC)
.action(async (opts) => {
await login(opts);
});
Expand Down Expand Up @@ -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" },
Expand All @@ -308,7 +362,7 @@ Give AI agents better Clerk context: install the Clerk skills
.command("create")
.description("Create a new Clerk application")
.argument("<name>", "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" },
Expand Down Expand Up @@ -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 <number>", "Maximum users to return (1-250, default 100)", (value) =>
parseIntegerOption(value, "--limit", { min: 1, max: 250 }),
)
Expand Down Expand Up @@ -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>", "Email address")
.option("--phone <phone>", "Phone number")
.option("--username <username>", "Username")
Expand Down Expand Up @@ -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([
Expand Down
12 changes: 12 additions & 0 deletions packages/cli-core/src/commands/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>`:

- `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`:
Expand Down
96 changes: 82 additions & 14 deletions packages/cli-core/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string> {
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<UserInfo> {
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<UserInfo | null> {
Expand Down Expand Up @@ -90,19 +154,28 @@ async function performOAuthFlow(): Promise<UserInfo> {
return userInfo;
}

function finishLogin(message: string | readonly string[], showNextSteps: boolean): void {
outro(showNextSteps ? message : "Done");
}

export async function login(options: LoginOptions = {}): Promise<UserInfo> {
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;
}

Expand All @@ -127,12 +200,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
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;
}
Expand Down
Loading