Merged
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
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
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

#### 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`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { 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";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand DownExpand Up@@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

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");
}
return existingSession;
}

Expand All@@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All@@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All@@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand DownExpand Up@@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand DownExpand Up@@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand DownExpand Up@@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
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

#### 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`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { 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";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand DownExpand Up@@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

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");
}
return existingSession;
}

Expand All@@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All@@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All@@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand DownExpand Up@@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand DownExpand Up@@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand DownExpand Up@@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
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

#### 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`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { 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";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand DownExpand Up@@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

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");
}
return existingSession;
}

Expand All@@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All@@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All@@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand DownExpand Up@@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand DownExpand Up@@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand DownExpand Up@@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
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

#### 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`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { 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";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand DownExpand Up@@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

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");
}
return existingSession;
}

Expand All@@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All@@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All@@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand DownExpand Up@@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand DownExpand Up@@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand DownExpand Up@@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
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

#### 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`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { 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";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand DownExpand Up@@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

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");
}
return existingSession;
}

Expand All@@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All@@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All@@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand DownExpand Up@@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand DownExpand Up@@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand DownExpand Up@@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
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

#### 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`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { 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";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand DownExpand Up@@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

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");
}
return existingSession;
}

Expand All@@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All@@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All@@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand DownExpand Up@@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand DownExpand Up@@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand DownExpand Up@@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
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

#### 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`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { 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";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand DownExpand Up@@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

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");
}
return existingSession;
}

Expand All@@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All@@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All@@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand DownExpand Up@@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand DownExpand Up@@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand DownExpand Up@@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
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

#### 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`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { 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";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand DownExpand Up@@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

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");
}
return existingSession;
}

Expand All@@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All@@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All@@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand DownExpand Up@@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand DownExpand Up@@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand DownExpand Up@@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading