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/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, including the normal login fallback when needed. Agent init no longer creates, auto-selects, or auto-links a Clerk application when no app target is provided.
25 changes: 14 additions & 11 deletions packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Init Command

Initializes Clerk in a project by authenticating the user, linking a Clerk application, installing the SDK, pulling environment variables, and scaffolding framework-specific boilerplate.
Initializes Clerk in a project by detecting the framework, installing the SDK, and scaffolding framework-specific boilerplate. Depending on mode and framework support, init either uses keyless development keys or links to a real Clerk application and pulls environment variables.

## Usage

Expand Down Expand Up @@ -36,15 +36,18 @@ 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

## Flow

1. Gathers project context (framework, router variant, TypeScript, `src/` directory, package manager)
2. 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
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 + 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
3. **Authenticated mode only**: 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
Expand Down Expand Up @@ -78,7 +81,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 @@ -223,15 +226,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
113 changes: 113 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,119 @@ 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 launches login", async () => {
setup({ isAgent: true });
spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX);

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

expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false });
expect(linkMod.link).toHaveBeenCalledWith({
skipIfLinked: true,
app: "app_abc",
cwd: FAKE_CTX.cwd,
});
});

test("-y flag skips auth prompt and defaults to unauthenticated mode", async () => {
setup();
setupBootstrapSuccess();
Expand Down
58 changes: 39 additions & 19 deletions packages/cli-core/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,15 @@ type InitOptions = {

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

const frameworkOverride = options.framework
? (lookupFramework(options.framework) ?? undefined)
: undefined;

// 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 @@ -85,12 +86,21 @@ 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);
}
Expand All @@ -104,12 +114,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 @@ -205,28 +218,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;
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.

Refactoring resolveKeylessMode moved the if (!bootstrap) return false; early-return inside the if (ctx.framework.supportsKeyless) block. The non-keyless branch (which still ends in log.info("X requires API keys — keyless mode is not yet supported for this framework.")) is no longer guarded by bootstrap, so the log now fires on paths where keyless was never being considered:

  • Human mode re-running clerk init in an existing Vue / Express / Fastify project (previously silent).
  • Agent mode with --app on a non-keyless framework (the real-app flow is what's running; the user gets a misleading 'keyless not supported' note).
  • The new agent manual-setup path, where printBootstrapManualSetupInfo already prints a clearer 'requires API keys — set them up manually' block. Users see two near-duplicate messages back-to-back.

The new integration test init prints manual setup for non-keyless framework without an app target in agent mode only asserts expect(stderr).toContain("clerk init --app <app_id>"), which passes despite the duplicate output, so the regression isn't caught.

Suggested fix: short-circuit on the non-keyless framework before the function does anything else, and let the caller own user-facing copy:

function resolveKeylessMode({...}): boolean {
  if (!ctx.framework.supportsKeyless) return false;
  if (agent) return !hasRealAppTarget;
  if (!bootstrap) return false;
  return !authed;
}

If the bootstrap-of-non-keyless framework hint is still wanted, emit it at the bootstrap call site (e.g., next to printBootstrapManualSetupInfo) rather than from inside the resolver. Either way, please add a regression test that asserts stderr does not contain keyless mode is not yet supported for an existing-project non-keyless human-mode run.


// 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 Down
Loading
Loading