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-authed-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

`clerk init` in agent mode now creates and links a real Clerk application when the user is authenticated, instead of falling back to keyless setup. Keyless still runs in agent mode when the user is not authenticated, but authenticated agent runs leave the project properly linked with real development keys in `.env`.
9 changes: 6 additions & 3 deletions packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
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 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.
- 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:
- **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 + 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 + 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
Expand Down Expand Up @@ -81,7 +84,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). 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.
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) when the user is not authenticated. In agent mode, keyless-capable frameworks use keyless only when the user is not authenticated and no real app target is provided by `--app` or a linked profile; an authenticated agent run instead creates a real app named after the project and links it. 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
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ describe("init", () => {
skipIfLinked: true,
app: "app_abc",
cwd: FAKE_CTX.cwd,
createIfMissing: undefined,
});
});

Expand All @@ -156,6 +157,7 @@ describe("init", () => {
skipIfLinked: true,
app: "app_abc",
cwd: FAKE_CTX.cwd,
createIfMissing: undefined,
});
});

Expand Down Expand Up @@ -341,8 +343,8 @@ 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" });
test("agent mode with keyless framework goes keyless when unauthenticated", async () => {
setup({ isAgent: true, email: null });

const keylessCtx = {
...FAKE_CTX,
Expand All @@ -362,6 +364,35 @@ describe("init", () => {
expect(pullMod.pull).not.toHaveBeenCalled();
});

test("agent mode with keyless framework + authed creates and links a real app", 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);
// Override potential leakage from earlier tests that spy on resolveProfile
// with a non-undefined value but don't track those spies for restoration.
spyOn(config, "resolveProfile").mockResolvedValue(undefined);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }],
postInstructions: [],
});

await init({});

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

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

Expand Down Expand Up @@ -407,6 +438,7 @@ describe("init", () => {
skipIfLinked: true,
app: "app_abc",
cwd: keylessCtx.cwd,
createIfMissing: expect.any(String),
});
expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: keylessCtx.cwd });
});
Expand Down Expand Up @@ -451,6 +483,7 @@ describe("init", () => {
skipIfLinked: true,
app: "app_abc",
cwd: FAKE_CTX.cwd,
createIfMissing: expect.any(String),
});
});

Expand Down
35 changes: 25 additions & 10 deletions packages/cli-core/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError, errorMessage } 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,
Expand Down Expand Up @@ -97,12 +98,21 @@ export async function init(options: InitOptions = {}) {
});
ctx.keyless = keyless;

// Agents can auto-create an app on keyless-capable frameworks when authed (see
// `link`'s `createIfMissing` path). Non-keyless frameworks without a real app
// target still need explicit human input, so they fall through to manual setup.
const manualSetup =
!keyless && (agent ? !hasRealAppTarget : bootstrap != null && overrides.skipConfirm && !authed);
!keyless &&
(agent
? !hasRealAppTarget && !ctx.framework.supportsKeyless
: bootstrap != null && overrides.skipConfirm && !authed);

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

// Short-circuit on a fully-clean re-run so env pull / skills prompt don't
Expand Down Expand Up @@ -240,17 +250,18 @@ function resolveKeylessMode({
hasRealAppTarget: boolean;
}): boolean {
if (ctx.framework.supportsKeyless) {
if (agent) return !hasRealAppTarget;
// Authenticated (OAuth token or CLERK_PLATFORM_API_KEY) — use the
// authenticated flow so a real app is created/linked and real keys get
// pulled into .env. Otherwise fall back to keyless: the app runs on
// auto-generated dev keys and the user can connect their account later
// via `clerk auth login`.
if (agent) return !hasRealAppTarget && !authed;

// 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.
// pulled.
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
// can connect their account later via `clerk auth login`.
return !authed;
}

Expand All @@ -275,7 +286,11 @@ async function resolveAuthLabel(): Promise<string> {
return "";
}

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

Expand All @@ -290,7 +305,7 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
log.info(dim(label));
}

await link({ skipIfLinked: true, app, cwd });
await link({ skipIfLinked: true, app, cwd, createIfMissing });
}

// --- Keyless app setup ---
Expand Down
6 changes: 6 additions & 0 deletions packages/cli-core/src/commands/link/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ deterministic paths:
- if no unambiguous app can be determined, the command exits with a usage error
telling the caller to pass `--app`

`clerk init` calls `link` with an internal `createIfMissing` option (the
project name) so an authenticated agent run that finds no autolink match auto-
creates a fresh app named after the project instead of erroring. The CLI does
not expose `createIfMissing` as a flag — it is library-only and reserved for
`clerk init`'s flow.

## Flow

1. Resolves the normalized git remote URL (e.g., `github.com/org/repo`) for cross-clone matching
Expand Down
29 changes: 29 additions & 0 deletions packages/cli-core/src/commands/link/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,35 @@ describe("link", () => {
expect(mockSearch).not.toHaveBeenCalled();
expect(mockListApplications).not.toHaveBeenCalled();
});

test("creates and links a new app when createIfMissing is provided", async () => {
mockIsAgent.mockReturnValue(true);
mockGetToken.mockResolvedValue("token");
mockCreateApplication.mockResolvedValue({ ...mockApp, application_id: "app_new" });
consoleSpy = spyOn(console, "log").mockImplementation(() => {});

await runLink({ createIfMissing: "my-project" });

expect(mockCreateApplication).toHaveBeenCalledWith("my-project");
expect(mockFetchApplication).not.toHaveBeenCalled();
expect(mockSearch).not.toHaveBeenCalled();
expect(mockListApplications).not.toHaveBeenCalled();
expect(mockSetProfile).toHaveBeenCalled();
});

test("autolink takes precedence over createIfMissing when keys match", async () => {
mockIsAgent.mockReturnValue(true);
mockAutolink.mockResolvedValue({
path: "github.com/org/repo",
profile: { workspaceId: "", appId: "app_existing", instances: { development: "ins_1" } },
});
consoleSpy = spyOn(console, "log").mockImplementation(() => {});

await runLink({ createIfMissing: "my-project" });

expect(mockAutolink).toHaveBeenCalled();
expect(mockCreateApplication).not.toHaveBeenCalled();
});
});

describe("already linked", () => {
Expand Down
18 changes: 15 additions & 3 deletions packages/cli-core/src/commands/link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { confirm } from "../../lib/prompts.ts";
import { isAgent } from "../../mode.ts";
import { getToken } from "../../lib/credential-store.ts";
import { login } from "../auth/login.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { createApplication, fetchApplication, type Application } from "../../lib/plapi.ts";
import { appLabel, fetchAppsTolerantly, pickOrCreateApp } from "../../lib/app-picker.ts";
import { setProfile, resolveProfile, moveProfile } from "../../lib/config.ts";
import { autolink, findClerkKeys, matchKeyToApp } from "../../lib/autolink.ts";
Expand All @@ -18,6 +18,13 @@ interface LinkOptions {
app?: string;
skipIfLinked?: boolean;
cwd?: string;
/**
* In agent mode without `--app` and no existing profile, auto-create a new
* Clerk application with this name and link to it instead of failing with a
* usage error. Used by `clerk init` to keep the authed-agent flow non-
* interactive end-to-end.
*/
createIfMissing?: string;
}

export async function link(options: LinkOptions = {}): Promise<void> {
Expand All @@ -42,7 +49,7 @@ export async function link(options: LinkOptions = {}): Promise<void> {
if (autolinked) return;
}

if (agent && !existing && !options.app) {
if (agent && !existing && !options.app && !options.createIfMissing) {
throwUsageError(
"Cannot select an application in agent mode. Pass --app <id>, or run `clerk apps list --json` and retry.",
);
Expand All @@ -68,7 +75,12 @@ export async function link(options: LinkOptions = {}): Promise<void> {

const app = options.app
? await withApiContext(fetchApplication(options.app), "Failed to fetch application")
: await resolveApp(cwd, displayPath, !existing);
: agent && options.createIfMissing
? await withApiContext(
createApplication(options.createIfMissing),
"Failed to create application",
)
: await resolveApp(cwd, displayPath, !existing);

const devInstance = app.instances.find((i) => i.environment_type === "development");
const prodInstance = app.instances.find((i) => i.environment_type === "production");
Expand Down
27 changes: 2 additions & 25 deletions packages/cli-core/src/lib/autoclaim.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { basename, join } from "node:path";
import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts";
import { claimApplication, type Application } from "./plapi.ts";
import { PlapiError, errorMessage } from "./errors.ts";
import { linkApp } from "./autolink.ts";
import { deriveProjectName } from "./project-name.ts";
import { pull } from "../commands/env/pull.ts";
import { log } from "./log.ts";

Expand All @@ -15,40 +15,17 @@ export type AutoclaimResult = Claimed | Terminal | Failed | Skipped;

type ClaimAttempt = { status: "claimed"; app: Application } | Terminal | Failed;

const APP_NAME_MAX_CHARS = 50;

const TERMINAL_BY_STATUS: Record<number, Terminal["status"]> = {
404: "not_found",
403: "no_organization",
};

async function deriveAppName(cwd: string): Promise<string> {
try {
const pkg: { name?: unknown } = await Bun.file(join(cwd, "package.json")).json();
if (typeof pkg.name === "string" && pkg.name.trim()) return pkg.name.trim();
} catch {
// fall through
}
return basename(cwd);
}

function truncateToChars(str: string, max: number): string {
const segments = [...new Intl.Segmenter().segment(str)];
return segments.length <= max
? str
: segments
.slice(0, max)
.map((s) => s.segment)
.join("");
}

/** Orchestrates post-login claim of a keyless app. Never throws. */
export async function attemptAutoclaim(cwd: string): Promise<AutoclaimResult> {
const breadcrumb = await readKeylessBreadcrumb(cwd);
if (!breadcrumb) return { status: "not_keyless" };

const rawName = await deriveAppName(cwd);
const appName = truncateToChars(rawName, APP_NAME_MAX_CHARS);
const appName = await deriveProjectName(cwd);
const result = await tryClaim(breadcrumb.claimToken, appName);

if (result.status === "failed") return result;
Expand Down
32 changes: 32 additions & 0 deletions packages/cli-core/src/lib/project-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { basename, join } from "node:path";

const APP_NAME_MAX_CHARS = 50;

function truncateToChars(str: string, max: number): string {
const segments = [...new Intl.Segmenter().segment(str)];
return segments.length <= max
? str
: segments
.slice(0, max)
.map((s) => s.segment)
.join("");
}

/**
* Derives a Clerk application name from the current project. Reads
* `package.json#name` first, then falls back to the directory basename.
* Result is truncated to a length safe for the PLAPI app-name field.
*/
export async function deriveProjectName(cwd: string, override?: string): Promise<string> {
if (override?.trim()) return truncateToChars(override.trim(), APP_NAME_MAX_CHARS);

try {
const pkg: { name?: unknown } = await Bun.file(join(cwd, "package.json")).json();
if (typeof pkg.name === "string" && pkg.name.trim()) {
return truncateToChars(pkg.name.trim(), APP_NAME_MAX_CHARS);
}
} catch {
// fall through
}
return truncateToChars(basename(cwd), APP_NAME_MAX_CHARS);
}
Loading
Loading