diff --git a/.changeset/agent-init-authed-link.md b/.changeset/agent-init-authed-link.md new file mode 100644 index 00000000..801ebb36 --- /dev/null +++ b/.changeset/agent-init-authed-link.md @@ -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`. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 1d656f30..bd5f6f5d 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -36,7 +36,9 @@ 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 ` or link the project first to pull real keys ## Flow @@ -44,7 +46,8 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful 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 @@ -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. diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 450a24bd..4e36b988 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -142,6 +142,7 @@ describe("init", () => { skipIfLinked: true, app: "app_abc", cwd: FAKE_CTX.cwd, + createIfMissing: undefined, }); }); @@ -156,6 +157,7 @@ describe("init", () => { skipIfLinked: true, app: "app_abc", cwd: FAKE_CTX.cwd, + createIfMissing: undefined, }); }); @@ -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, @@ -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" }); @@ -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 }); }); @@ -451,6 +483,7 @@ describe("init", () => { skipIfLinked: true, app: "app_abc", cwd: FAKE_CTX.cwd, + createIfMissing: expect.any(String), }); }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index d89580f8..664926cd 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -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, @@ -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 @@ -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; } @@ -275,7 +286,11 @@ async function resolveAuthLabel(): Promise { return ""; } -async function authenticateAndLink(cwd: string, app: string | undefined): Promise { +async function authenticateAndLink( + cwd: string, + app: string | undefined, + createIfMissing: string | undefined, +): Promise { const label = await resolveAuthLabel(); const profile = await resolveProfile(cwd); @@ -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 --- diff --git a/packages/cli-core/src/commands/link/README.md b/packages/cli-core/src/commands/link/README.md index 0f303a3c..2797566c 100644 --- a/packages/cli-core/src/commands/link/README.md +++ b/packages/cli-core/src/commands/link/README.md @@ -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 diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index bbad9dda..e7627af7 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -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", () => { diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 796be87d..8e406a14 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -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"; @@ -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 { @@ -42,7 +49,7 @@ export async function link(options: LinkOptions = {}): Promise { 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 , or run `clerk apps list --json` and retry.", ); @@ -68,7 +75,12 @@ export async function link(options: LinkOptions = {}): Promise { 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"); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 9df13dc2..c71bf053 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -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"; @@ -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 = { 404: "not_found", 403: "no_organization", }; -async function deriveAppName(cwd: string): Promise { - 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 { 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; diff --git a/packages/cli-core/src/lib/project-name.ts b/packages/cli-core/src/lib/project-name.ts new file mode 100644 index 00000000..86e5f93a --- /dev/null +++ b/packages/cli-core/src/lib/project-name.ts @@ -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 { + 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); +} diff --git a/packages/cli-core/src/test/integration/agent-mode.test.ts b/packages/cli-core/src/test/integration/agent-mode.test.ts index 35058867..50ff159d 100644 --- a/packages/cli-core/src/test/integration/agent-mode.test.ts +++ b/packages/cli-core/src/test/integration/agent-mode.test.ts @@ -104,25 +104,23 @@ test("unlink --yes removes the profile in agent mode", async () => { expect(config.profiles["github.com/test/project"]).toBeUndefined(); }); -test("init uses keyless for keyless framework without an app target in agent mode", async () => { +test("init creates and links a real app for keyless framework when authed in agent mode", async () => { await writeNextAppProject(); + const devInstance = getInstance(MOCK_APP, "development"); http.mock({ - "/v1/accountless_applications": { - publishable_key: "pk_test_keyless", - secret_key: "sk_test_keyless", - claim_url: "/apps/claim?token=keyless_token", - }, + "/v1/platform/applications": MOCK_APP, }); await clerk("--mode", "agent", "init", "--no-skills"); const env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); - expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe("pk_test_keyless"); - expect(env.get("CLERK_SECRET_KEY")).toBe("sk_test_keyless"); + expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(devInstance.publishable_key); + expect(env.get("CLERK_SECRET_KEY")).toBe(devInstance.secret_key); const config = await readConfig(); - expect(config.profiles["github.com/test/project"]).toBeUndefined(); - expect(http.requests.some((r) => r.url.includes("/v1/platform/applications"))).toBe(false); + expect(config.profiles["github.com/test/project"]?.appId).toBe(MOCK_APP.application_id); + // Accountless endpoint should not be hit when the user is already authed. + expect(http.requests.some((r) => r.url.includes("/v1/accountless_applications"))).toBe(false); }); test("init prints manual setup for non-keyless framework without an app target in agent mode", async () => {