Skip to content
Open
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/agent-init-keyless.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Allow `clerk init` to run in agent mode without requiring `--app`. For keyless-capable frameworks, agent init now uses keyless setup when no real Clerk app target is provided; explicit `--app` or an existing linked profile still uses the authenticated app-linking flow. Agent init no longer creates, auto-selects, or auto-links a Clerk application when no app target is provided.
23 changes: 13 additions & 10 deletions packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,21 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful
- For **existing projects**: framework and package manager are auto-detected, no flags required
- 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, init uses keyless and does not require auth
- 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

Use `--prompt` to output a setup prompt for an AI agent without running init.

## Flow

1. **`--prompt`**: outputs a framework-specific prompt, then exits
2. Gathers project context (framework, router variant, TypeScript, `src/` directory, package manager)
3. Determines auth mode from credential presence (no user prompt):
- **Authenticated** (OAuth token or `CLERK_PLATFORM_API_KEY` set): uses the authenticated flow — runs `clerk link` if not already linked and pulls real API keys into `.env` at the end
- **Bootstrap + keyless-capable framework + not authenticated**: automatically uses keyless mode — the app runs on auto-generated dev keys and the user can connect a Clerk account later with `clerk auth login`
- **Bootstrap + non-keyless framework + not authenticated** (with `--yes` or agent mode): skips authentication and prints manual setup instructions (run `clerk auth login` / `clerk link` / `clerk env pull` when ready)
- **Existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled
3. 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 + 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 + 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
4. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked)
5. Displays detected framework and variant
6. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance
Expand Down Expand Up @@ -83,7 +86,7 @@ Detects the project's framework from `package.json` dependencies (checked top-to
| `express` | Express | `@clerk/express` | `CLERK_PUBLISHABLE_KEY` | No |
| `fastify` | Fastify | `@clerk/fastify` | `CLERK_PUBLISHABLE_KEY` | No |

The **Keyless** column indicates whether the framework's Clerk SDK supports keyless mode (auto-generated temporary dev keys). Keyless auto-selection only applies during bootstrap (new projects) — re-running `clerk init` in an existing project always uses the authenticated flow (prompting login when signed out) so real keys can be pulled via `clerk env pull`. During bootstrap of a non-keyless framework with `--yes` and no credentials, `clerk init` skips authentication and prints manual setup instructions instead of blocking on a login prompt.
The **Keyless** column indicates whether the framework's Clerk SDK supports keyless mode (auto-generated temporary dev keys). In human mode, keyless auto-selection only applies during bootstrap (new projects). In agent mode, keyless-capable frameworks use keyless whenever no real app target is provided by `--app` or a linked profile. For non-keyless frameworks without a real app target, agent mode prints manual setup instructions instead of selecting or creating an app.

Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `yarn.lock` → yarn, `pnpm-lock.yaml` → pnpm, else npm.

Expand Down Expand Up @@ -229,15 +232,15 @@ 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. |
| 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.

## 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.
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.

```json
{
Expand Down
111 changes: 111 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,117 @@ describe("init", () => {
expect(linkMod.link).not.toHaveBeenCalled();
});

test("agent mode with keyless framework uses keyless without an app target", async () => {
setup({ isAgent: true, 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({});

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

test("agent mode with keyless framework uses linked profile as a real app target", async () => {
setup({ isAgent: true, email: "user@example.com" });

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

await init({});

expect(heuristics.printKeylessInfo).not.toHaveBeenCalled();
expect(linkMod.link).not.toHaveBeenCalled();
expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: keylessCtx.cwd });
});

test("agent mode with keyless framework and --app uses real app flow", async () => {
setup({ isAgent: true, 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_abc" });

expect(heuristics.printKeylessInfo).not.toHaveBeenCalled();
expect(linkMod.link).toHaveBeenCalledWith({
skipIfLinked: true,
app: "app_abc",
cwd: keylessCtx.cwd,
});
expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: keylessCtx.cwd });
});

test("agent mode with non-keyless framework and no app target prints manual setup", async () => {
const { captured } = setup({ isAgent: true, email: "user@example.com" });

const noKeylessCtx = {
...FAKE_CTX,
existingClerk: false,
framework: {
dep: "vue",
name: "Vue",
sdk: "@clerk/vue",
envVar: "VITE_CLERK_PUBLISHABLE_KEY",
envFile: ".env.local" as const,
},
envFile: ".env.local",
};
spyOn(context, "gatherContext").mockResolvedValue(noKeylessCtx);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "src/main.ts", content: "", description: "" }],
postInstructions: [],
});

await captured.run(() => init({}));

expect(linkMod.link).not.toHaveBeenCalled();
expect(pullMod.pull).not.toHaveBeenCalled();
expect(loginMod.login).not.toHaveBeenCalled();
expect(captured.err).toContain("clerk init --app <app_id>");
});

test("agent mode with real app target and no auth fails without launching login", async () => {
setup({ isAgent: true });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);

await expect(init({ app: "app_abc" })).rejects.toThrow(
"clerk init in agent mode requires CLERK_PLATFORM_API_KEY",
);

expect(loginMod.login).not.toHaveBeenCalled();
expect(linkMod.link).not.toHaveBeenCalled();
});

test("-y flag skips auth prompt and defaults to unauthenticated mode", async () => {
setup();
setupBootstrapSuccess();
Expand Down
82 changes: 58 additions & 24 deletions packages/cli-core/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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, errorMessage, throwUsageError } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
Expand Down Expand Up @@ -58,6 +58,7 @@ type InitOptions = {

export async function init(options: InitOptions = {}) {
const cwd = process.cwd();
const agent = isAgent();

const frameworkOverride = options.framework
? (lookupFramework(options.framework) ?? undefined)
Expand All @@ -72,7 +73,7 @@ export async function init(options: InitOptions = {}) {

// In agent mode, implicitly enable --yes to skip all confirmation prompts.
const overrides: BootstrapOverrides = {
skipConfirm: options.yes || isAgent(),
skipConfirm: options.yes || agent,
pmOverride: options.pm,
nameOverride: options.name,
};
Expand All @@ -94,14 +95,23 @@ export async function init(options: InitOptions = {}) {
await enrichProjectContext(ctx);

const authed = await isAuthenticated();
const keyless = resolveKeylessMode(bootstrap, ctx, authed);
const linkedProfile = agent && !options.app ? await resolveProfile(ctx.cwd) : undefined;
const hasRealAppTarget = Boolean(options.app || linkedProfile);
const keyless = resolveKeylessMode({
agent,
bootstrap,
ctx,
authed,
hasRealAppTarget,
});
ctx.keyless = keyless;

const skipAuth = !keyless && bootstrap != null && overrides.skipConfirm && !authed;
const manualSetup =
!keyless && (agent ? !hasRealAppTarget : bootstrap != null && overrides.skipConfirm && !authed);

if (!keyless && !skipAuth) {
if (!keyless && !manualSetup) {
bar();
await authenticateAndLink(ctx.cwd, options.app);
await authenticateAndLink(ctx.cwd, options.app, { allowInteractiveLogin: !agent });
}

// Short-circuit on a fully-clean re-run so env pull / skills prompt don't
Expand All @@ -113,12 +123,15 @@ export async function init(options: InitOptions = {}) {

if (alreadySetUp) {
log.success("\nClerk is already set up in this project.");
if (agent && manualSetup) {
printBootstrapManualSetupInfo(ctx.framework.name);
}
outro("Done");
return;
}

bar();
if (skipAuth) {
if (manualSetup) {
printBootstrapManualSetupInfo(ctx.framework.name);
} else if (!keyless) {
await pull({ file: ctx.envFile, cwd: ctx.cwd });
Expand Down Expand Up @@ -214,28 +227,35 @@ function printBootstrapNextSteps(
function printBootstrapManualSetupInfo(frameworkName: string): void {
const lines = [
`\n ${frameworkName} requires API keys — set them up manually:`,
" clerk auth login",
" clerk link",
" clerk init --app <app_id>",
" clerk env pull",
];
log.info(lines.map(dim).join("\n"));
}

// --- Keyless ---

function resolveKeylessMode(
bootstrap: BootstrapResult | null,
ctx: ProjectContext,
authed: boolean,
): boolean {
// Auto-keyless is scoped to bootstrap (new-project) flows only. For existing
// projects, fall through to the authenticated flow so `clerk init` still
// runs `authenticateAndLink` and pulls real keys — even when signed out the
// user gets a login prompt rather than being silently dropped into keyless
// (which would skip `env pull` and could overwrite permissive middleware).
if (!bootstrap) return false;

function resolveKeylessMode({
agent,
bootstrap,
ctx,
authed,
hasRealAppTarget,
}: {
agent: boolean;
bootstrap: BootstrapResult | null;
ctx: ProjectContext;
authed: boolean;
hasRealAppTarget: boolean;
}): boolean {
if (ctx.framework.supportsKeyless) {
if (agent) return !hasRealAppTarget;

// Auto-keyless is scoped to bootstrap (new-project) flows only in human
// mode. Existing projects keep the authenticated flow so real keys can be
// pulled unless an agent explicitly chooses keyless by omitting an app.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

to be honest i'm not sure this is what we want, but its what we were doing before. i find the fact that clerk init doesn't require auth for bootstrapping and does require auth in existing projects a bit odd

if (!bootstrap) return false;

// Authenticated (OAuth token or CLERK_PLATFORM_API_KEY) — use the
// authenticated flow so real keys get pulled into .env. Otherwise fall
// back to keyless: the app runs on auto-generated dev keys and the user
Expand All @@ -253,19 +273,33 @@ function resolveKeylessMode(

// --- Auth ---

async function resolveAuthLabel(): Promise<string> {
async function resolveAuthLabel({
allowInteractiveLogin,
}: {
allowInteractiveLogin: boolean;
}): Promise<string> {
const hasApiKey = Boolean(process.env.CLERK_PLATFORM_API_KEY);
if (hasApiKey) return "Using API key";

const email = await getAuthenticatedEmail();
if (email) return `Logged in as ${email}`;

if (!allowInteractiveLogin) {
throwUsageError(
"clerk init in agent mode requires CLERK_PLATFORM_API_KEY or a valid stored session when using a real Clerk application. Pass --app only with auth available, or omit --app for keyless-capable frameworks.",
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This throws USAGE_ERROR but it is actually an auth problem, not a syntax issue. Agents that branch on error codes (as the ERROR_CODE JSDoc suggests) would misinterpret this as a bad command instead of missing credentials.

Suggested change
}
throwUsageError(
"clerk init in agent mode requires CLERK_PLATFORM_API_KEY or a valid stored session when using a real Clerk application. Pass --app only with auth available, or omit --app for keyless-capable frameworks.",
undefined,
ERROR_CODE.AUTH_REQUIRED,
);


await login({ showNextSteps: false });
return "";
}

async function authenticateAndLink(cwd: string, app: string | undefined): Promise<void> {
const label = await resolveAuthLabel();
async function authenticateAndLink(
cwd: string,
app: string | undefined,
options: { allowInteractiveLogin: boolean },
): Promise<void> {
const label = await resolveAuthLabel(options);
const profile = await resolveProfile(cwd);

const alreadyOnRequestedApp = profile && (!app || profile.profile.appId === app);
Expand Down
Loading
Loading