Skip to content
Closed
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/keyless-bootstrap-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Delegate keyless mode to the SDK during `clerk init` instead of writing temporary keys. When an authenticated user runs `clerk init` with an existing SDK keyless breadcrumb, automatically claim the app and pull real keys in one step.
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or
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
8. **Autoclaim**: if a keyless breadcrumb exists in the current directory (SDK's `.clerk/.tmp/keyless.json` or CLI's legacy `.clerk/keyless.json`), 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`:
When the Clerk SDK runs in keyless mode (no API keys in `.env`), it writes `.clerk/.tmp/keyless.json` containing the temporary keys and a claim URL. On the next `clerk auth login` (or `clerk init` when already authenticated):

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

#### API Endpoints

Expand Down
29 changes: 13 additions & 16 deletions packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful
- For **new projects** (`--starter` or blank directory): `--framework` is required (no way to auto-detect in an empty dir). Package manager is auto-selected by availability (bun → pnpm → yarn → npm) unless `--pm` is provided
- Project name defaults to the framework's default (e.g. `my-clerk-next-app`) unless `--name` is provided
- For keyless-capable frameworks with no `--app` and no linked profile:
- When **authenticated**, init creates a real Clerk app named after the project (`package.json#name`, `--name`, or directory basename) and links it. No keyless detour, no second `clerk auth login` to claim.
- When **unauthenticated**, init uses keyless and writes a breadcrumb so the next `clerk auth login` claims the app automatically.
- When **authenticated**, init first attempts to autoclaim any existing SDK keyless breadcrumb (`.clerk/.tmp/keyless.json`). If no breadcrumb exists, it creates a real Clerk app named after the project and links it.
- When **unauthenticated**, init uses keyless mode — the app scaffolds without API keys and the SDK handles keyless mode at runtime.
- For frameworks that require API keys, init will not pick or create an app in agent mode; pass `--app <id>` or link the project first to pull real keys

## Flow
Expand All @@ -47,11 +47,11 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful
2. Determines auth mode:
- **Real app target** (`--app` or linked profile): authenticates, links if needed, and pulls real API keys into `.env`
- **Agent + keyless-capable framework + authenticated + no real app target**: creates a real Clerk app named after the project, links it, and pulls real API keys into `.env`
- **Agent + keyless-capable framework + unauthenticated + no real app target**: uses keyless mode — the app runs on auto-generated dev keys and the user can connect a Clerk account later with `clerk auth login`
- **Agent + keyless-capable framework + unauthenticated + no real app target**: uses keyless mode — the SDK handles development keys at runtime and the user can connect a Clerk account later with `clerk auth login`
- **Agent + non-keyless framework + no real app target**: scaffolds locally and prints manual setup instructions instead of selecting or creating an app
- **Human mode + bootstrap + keyless-capable framework + not authenticated**: uses keyless mode
- **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled
3. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked)
3. **Authenticated mode only**: attempts autoclaim of any SDK keyless breadcrumb; if none found, authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked)
4. Displays detected framework and variant
5. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance
6. Installs the appropriate Clerk SDK (skips if already present)
Expand Down Expand Up @@ -229,21 +229,18 @@ 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 keyless mode. |

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.

## Autoclaim during init

When an authenticated user runs `clerk init` without `--app`, the CLI checks for a keyless breadcrumb before falling through to the normal `authenticateAndLink` flow. If a breadcrumb is found, the CLI claims the temporary application via `POST /v1/platform/accountless_applications/claim`, links it locally, and pulls real API keys — skipping the interactive app picker entirely.

## Keyless breadcrumb

In keyless mode, 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.
The CLI reads keyless breadcrumbs from two sources:

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```
1. **SDK breadcrumb** (`.clerk/.tmp/keyless.json`) — written by `@clerk/nextjs` (and other Clerk SDKs) at runtime when the app starts without API keys. Contains `publishableKey`, `secretKey`, `claimUrl`, and `apiKeysUrl`. The claim token is extracted from `claimUrl`.

2. **CLI breadcrumb** (`.clerk/keyless.json`) — legacy format written by older versions of the CLI. Contains `claimToken` and `createdAt`.

`.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).
The SDK breadcrumb is checked first (it represents the most recent keyless state). Both breadcrumbs are cleared after a successful claim or terminal error (404/403). On transient failures (500, 429), breadcrumbs are preserved for retry on the next `clerk auth login`.
148 changes: 145 additions & 3 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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 * as autoclaimMod from "../../lib/autoclaim.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand Down Expand Up @@ -99,6 +100,7 @@ describe("init", () => {
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
spyOn(autoclaimMod, "attemptAutoclaim").mockResolvedValue({ status: "not_keyless" }),
];

return { gatherContextSpy, captured };
Expand Down Expand Up @@ -271,7 +273,7 @@ describe("init", () => {
await init({});

expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled();
expect(heuristics.printKeylessInfo).toHaveBeenCalled();
expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled();
expect(linkMod.link).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -339,7 +341,7 @@ describe("init", () => {
await init({ yes: true });

expect(heuristics.isAuthenticated).toHaveBeenCalled();
expect(heuristics.printKeylessInfo).toHaveBeenCalled();
expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled();
expect(linkMod.link).not.toHaveBeenCalled();
});

Expand All @@ -359,7 +361,7 @@ describe("init", () => {

await init({});

expect(heuristics.printKeylessInfo).toHaveBeenCalled();
expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled();
expect(linkMod.link).not.toHaveBeenCalled();
expect(pullMod.pull).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -904,4 +906,144 @@ describe("init", () => {
cwd: FAKE_BOOTSTRAP.projectDir,
});
});

// --- Autoclaim during init ---

test("authenticated init autoclaims SDK breadcrumb and skips link + pull", async () => {
setup({ email: "user@example.com" });

const keylessCtx = {
...FAKE_CTX,
existingClerk: false,
framework: { ...FAKE_CTX.framework, supportsKeyless: true },
};
spyOn(context, "gatherContext").mockResolvedValue(keylessCtx);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }],
postInstructions: [],
});
spyOn(autoclaimMod, "attemptAutoclaim").mockResolvedValue({
status: "claimed",
app: { application_id: "app_claimed", name: "My App", instances: [] },
envPulled: true,
});

await init({});

expect(autoclaimMod.attemptAutoclaim).toHaveBeenCalled();
expect(linkMod.link).not.toHaveBeenCalled();
expect(pullMod.pull).not.toHaveBeenCalled();
});

test("autoclaim with envPulled=false still pulls env", async () => {
setup({ email: "user@example.com" });

const keylessCtx = {
...FAKE_CTX,
existingClerk: false,
framework: { ...FAKE_CTX.framework, supportsKeyless: true },
};
spyOn(context, "gatherContext").mockResolvedValue(keylessCtx);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }],
postInstructions: [],
});
spyOn(autoclaimMod, "attemptAutoclaim").mockResolvedValue({
status: "claimed",
app: { application_id: "app_claimed", name: "My App", instances: [] },
envPulled: false,
});

await init({});

expect(autoclaimMod.attemptAutoclaim).toHaveBeenCalled();
// Claim succeeded so link should not be called again
expect(linkMod.link).not.toHaveBeenCalled();
// But env pull failed during autoclaim, so init should retry it
expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: keylessCtx.cwd });
});

test("autoclaim returning not_keyless falls through to authenticateAndLink", async () => {
setup({ email: "user@example.com" });

const keylessCtx = {
...FAKE_CTX,
existingClerk: false,
framework: { ...FAKE_CTX.framework, supportsKeyless: true },
};
spyOn(context, "gatherContext").mockResolvedValue(keylessCtx);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }],
postInstructions: [],
});
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);

await init({});

expect(autoclaimMod.attemptAutoclaim).toHaveBeenCalled();
expect(pullMod.pull).toHaveBeenCalled();
});

test("autoclaim returning failed falls through to authenticateAndLink", async () => {
setup({ email: "user@example.com" });

const keylessCtx = {
...FAKE_CTX,
existingClerk: false,
framework: { ...FAKE_CTX.framework, supportsKeyless: true },
};
spyOn(context, "gatherContext").mockResolvedValue(keylessCtx);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }],
postInstructions: [],
});
spyOn(autoclaimMod, "attemptAutoclaim").mockResolvedValue({
status: "failed",
error: new Error("temporary"),
});
spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never);

await init({});

expect(linkMod.link).not.toHaveBeenCalled();
expect(pullMod.pull).toHaveBeenCalled();
});

test("autoclaim is not attempted when --app is provided", async () => {
setup({ email: "user@example.com" });

const keylessCtx = {
...FAKE_CTX,
existingClerk: false,
framework: { ...FAKE_CTX.framework, supportsKeyless: true },
};
spyOn(context, "gatherContext").mockResolvedValue(keylessCtx);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }],
postInstructions: [],
});

await init({ app: "app_specific" });

expect(autoclaimMod.attemptAutoclaim).not.toHaveBeenCalled();
});

test("autoclaim is not attempted when user is unauthenticated", async () => {
setup();

const keylessCtx = {
...FAKE_CTX,
existingClerk: false,
framework: { ...FAKE_CTX.framework, supportsKeyless: true },
};
spyOn(context, "gatherContext").mockResolvedValueOnce(null).mockResolvedValueOnce(keylessCtx);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }],
postInstructions: [],
});

await init({});

expect(autoclaimMod.attemptAutoclaim).not.toHaveBeenCalled();
});
});
63 changes: 25 additions & 38 deletions packages/cli-core/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,12 @@ 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, errorMessage } from "../../lib/errors.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { deriveProjectName } from "../../lib/project-name.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { attemptAutoclaim } from "../../lib/autoclaim.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand All @@ -26,7 +21,6 @@ import {
writePlan,
checkGitDirty,
printOutro,
printKeylessInfo,
getAuthenticatedEmail,
isAuthenticated,
} from "./heuristics.js";
Expand Down Expand Up @@ -107,12 +101,19 @@ export async function init(options: InitOptions = {}) {
? !hasRealAppTarget && !ctx.framework.supportsKeyless
: bootstrap != null && overrides.skipConfirm && !authed);

let autoclaimPulled = false;
if (!keyless && !manualSetup) {
bar();
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
: undefined;
await authenticateAndLink(ctx.cwd, options.app, createIfMissing);

const autoclaim = !options.app && authed && (await tryInitAutoclaim(ctx.cwd));
if (!autoclaim) {
const createIfMissing = agent
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
: undefined;
await authenticateAndLink(ctx.cwd, options.app, createIfMissing);
} else {
autoclaimPulled = autoclaim.envPulled;
}
Comment on lines +104 to +116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle partial autoclaim success before skipping normal link/env pull

tryInitAutoclaim treats every status: "claimed" as fully complete, but attemptAutoclaim can return { status: "claimed", envPulled: false } when local linking or env pull failed. The current flow then skips both authenticateAndLink() and the later pull(), which can leave the project not fully configured.

Suggested fix
-  let autoclaimPulled = false;
+  let autoclaimPulled = false;
   if (!keyless && !manualSetup) {
     bar();

-    const claimed = !options.app && authed && (await tryInitAutoclaim(ctx.cwd));
-    if (!claimed) {
+    const autoclaim = !options.app && authed ? await tryInitAutoclaim(ctx.cwd) : { claimed: false, envPulled: false };
+    autoclaimPulled = autoclaim.envPulled;
+    if (!autoclaim.claimed || !autoclaim.envPulled) {
       const createIfMissing = agent
         ? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
         : undefined;
       await authenticateAndLink(ctx.cwd, options.app, createIfMissing);
-    } else {
-      autoclaimPulled = true;
     }
   }
-async function tryInitAutoclaim(cwd: string): Promise<boolean> {
+async function tryInitAutoclaim(
+  cwd: string,
+): Promise<{ claimed: boolean; envPulled: boolean }> {
   const result = await attemptAutoclaim(cwd);
   if (result.status === "claimed") {
     const label = result.app.name || result.app.application_id;
-    log.success(`Claimed and linked \`${label}\``);
-    return true;
+    if (result.envPulled) {
+      log.success(`Claimed and linked \`${label}\``);
+    } else {
+      log.warn(`Claimed \`${label}\`, but local setup was incomplete; continuing setup.`);
+    }
+    return { claimed: true, envPulled: result.envPulled };
   }
   if (result.status !== "not_keyless") {
     log.debug(`init: autoclaim returned '${result.status}', falling through to link`);
   }
-  return false;
+  return { claimed: false, envPulled: false };
 }

Also applies to: 138-139, 322-333

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/commands/init/index.ts` around lines 104 - 116,
tryInitAutoclaim currently returns a result object but the code treats any
status === "claimed" as fully done; change the flow to inspect the full result
from tryInitAutoclaim/attemptAutoclaim and only skip
authenticateAndLink()/pull() when result.status === "claimed" AND
result.envPulled === true (set autoclaimPulled = true only in that case); if
envPulled is false (or missing) treat it as not fully claimed and proceed to
call authenticateAndLink(ctx.cwd, ...) and later pull() so local linking/envs
are completed; apply the same adjustment to the other tryInitAutoclaim usage
sites (the other blocks that currently set autoclaimPulled on any "claimed"
result).

}

// Short-circuit on a fully-clean re-run so env pull / skills prompt don't
Expand All @@ -134,10 +135,8 @@ export async function init(options: InitOptions = {}) {
bar();
if (manualSetup) {
printBootstrapManualSetupInfo(ctx.framework.name);
} else if (!keyless) {
} else if (!keyless && !autoclaimPulled) {
await pull({ file: ctx.envFile, cwd: ctx.cwd });
} else {
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand Down Expand Up @@ -318,31 +317,19 @@ async function authenticateAndLink(
await link({ skipIfLinked: true, app, cwd, createIfMissing });
}

// --- Keyless app setup ---
// --- Autoclaim ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
);

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.`,
);
async function tryInitAutoclaim(cwd: string): Promise<{ envPulled: boolean } | false> {
const result = await attemptAutoclaim(cwd);
if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked \`${label}\``);
return { envPulled: result.envPulled };
}
if (result.status !== "not_keyless") {
log.debug(`init: autoclaim returned '${result.status}', falling through to link`);
}
return false;
}

// --- Detect & install ---
Expand Down
Loading
Loading