From 201d8ce2dd859f6719aae49c5fd6d3f2e7d5b752 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 22 Apr 2026 11:52:43 -0600 Subject: [PATCH 1/3] fix(cli-core): support deterministic agent link flows --- packages/cli-core/src/commands/link/README.md | 12 +- .../cli-core/src/commands/link/index.test.ts | 62 +++++- packages/cli-core/src/commands/link/index.ts | 57 +++-- .../cli-core/src/commands/unlink/README.md | 4 +- .../src/commands/unlink/index.test.ts | 18 +- .../cli-core/src/commands/unlink/index.ts | 22 +- .../src/test/integration/agent-mode.test.ts | 42 ++-- .../src/test/integration/onboard.test.ts | 198 +++++++++--------- .../src/test/integration/switch-apps.test.ts | 88 ++++---- skills/clerk/SKILL.md | 50 +++-- skills/clerk/references/agent-mode.md | 9 +- skills/clerk/references/recipes.md | 11 + 12 files changed, 319 insertions(+), 254 deletions(-) diff --git a/packages/cli-core/src/commands/link/README.md b/packages/cli-core/src/commands/link/README.md index 8f288ad6..a319791a 100644 --- a/packages/cli-core/src/commands/link/README.md +++ b/packages/cli-core/src/commands/link/README.md @@ -20,9 +20,13 @@ clerk link --app app_abc123 # Link directly by app ID ## Agent Mode -When running in agent mode (`--mode agent` or piped stdin), outputs a structured -prompt describing how to perform the link operation instead of running the -interactive flow. +In agent mode (`--mode agent` or piped stdout), `clerk link` only runs through +deterministic paths: + +- `clerk link --app app_abc123` links directly +- bare `clerk link` first tries silent autolink from detected publishable keys +- if no unambiguous app can be determined, the command exits with a usage error + telling the caller to pass `--app` ## Flow @@ -40,7 +44,7 @@ interactive flow. 8. If no match (or no apps exist), presents a searchable picker (type to filter by name) - The picker always includes a "+ Create a new application" option pinned at the bottom - Selecting it prompts for a name and creates the app via the Platform API - - For non-interactive/CI flows, create apps from the Clerk Dashboard or via the Platform API, then pass `--app ` + - For non-interactive/CI/agent flows, create apps from the Clerk Dashboard or via the Platform API, then pass `--app ` 9. Stores the profile in the config file keyed by the normalized remote URL 10. Falls back to git-common-dir or the current directory path if no remote is configured diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index e237121f..bbad9dda 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -167,17 +167,30 @@ describe("link", () => { } describe("agent mode", () => { - test("outputs prompt and returns", async () => { + test("links directly with --app", async () => { mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockFetchApplication.mockResolvedValue(mockApp); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await runLink(); + await runLink({ app: "app_123" }); - expect(captured.out).toContain("linking a Clerk application"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockSetProfile).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + appId: "app_123", + instances: expect.objectContaining({ development: "ins_dev", production: "ins_prod" }), + }), + ); }); - test("does not trigger interactive prompts", async () => { + test("auto-links without prompts when a key match is available", async () => { mockIsAgent.mockReturnValue(true); + mockAutolink.mockResolvedValue({ + path: "github.com/org/repo", + profile: { workspaceId: "", appId: "app_auto", instances: { development: "ins_dev" } }, + }); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await runLink(); @@ -187,14 +200,49 @@ describe("link", () => { expect(mockListApplications).not.toHaveBeenCalled(); }); - test("prompt covers the create-app path", async () => { + test("returns current link status when already linked and no app is provided", async () => { mockIsAgent.mockReturnValue(true); + mockResolveProfile.mockResolvedValue({ + path: "/repo/.git", + profile: { workspaceId: "", appId: "app_existing", instances: { development: "ins_1" } }, + }); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await runLink(); - expect(captured.out).toContain("no applications exist"); - expect(captured.out).toContain("POST /v1/platform/applications"); + expect(captured.err).toContain("Already linked"); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockFetchApplication).not.toHaveBeenCalled(); + expect(mockSetProfile).not.toHaveBeenCalled(); + }); + + test("re-links directly when already linked and --app differs", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockResolveProfile.mockResolvedValue({ + path: "/repo/.git", + profile: { workspaceId: "", appId: "app_existing", instances: { development: "ins_1" } }, + }); + mockFetchApplication.mockResolvedValue(mockApp); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runLink({ app: "app_123" }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockSetProfile).toHaveBeenCalled(); + }); + + test("errors when no deterministic app selection is available", async () => { + mockIsAgent.mockReturnValue(true); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await expect(runLink()).rejects.toThrow( + "Cannot select an application in agent mode. Pass --app ", + ); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockListApplications).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 7ec0f81b..daaa17c9 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -16,29 +16,16 @@ import { autolink, findClerkKeys, matchKeyToApp } from "../../lib/autolink.ts"; import { getGitRepoIdentifier, getGitRepoRoot, getGitNormalizedRemote } from "../../lib/git.ts"; import { dim, cyan } from "../../lib/color.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; -import { CliError, PlapiError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; +import { + CliError, + PlapiError, + ERROR_CODE, + throwUsageError, + withApiContext, +} from "../../lib/errors.ts"; import { intro, outro, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; -const AGENT_PROMPT = `You are linking a Clerk application to the current project directory. - -## Steps - -1. Ensure the user is authenticated. If not, run \`clerk auth login\` first. -2. Determine which application to link: - - If the user provides an app ID: \`clerk link --app \` - - Otherwise, list available applications with \`GET /v1/platform/applications\` and ask the user to select one. - - If no applications exist, or the user wants a new one, create one with \`POST /v1/platform/applications\`, then fetch its details with \`GET /v1/platform/applications/{appId}\`. -3. The link is stored in ~/.clerk/config.json as a profile keyed by the git repository root (shared across worktrees). - -## API Endpoints - -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | /v1/platform/applications | List all applications | -| GET | /v1/platform/applications/{appId} | Fetch application with instance details | -| POST | /v1/platform/applications | Create a new application |`; - const CREATE_NEW_APP = "__create_new__"; interface LinkOptions { @@ -52,11 +39,7 @@ function appLabel(app: Application): string { } export async function link(options: LinkOptions = {}): Promise { - if (isAgent()) { - log.data(AGENT_PROMPT); - return; - } - + const agent = isAgent(); const cwd = options.cwd ?? process.cwd(); const repoRoot = await getGitRepoRoot(cwd); const normalizedRemote = await getGitNormalizedRemote(cwd); @@ -72,18 +55,32 @@ export async function link(options: LinkOptions = {}): Promise { return; } - if (!existing && options.skipIfLinked && !options.app) { + if (!existing && !options.app && (options.skipIfLinked || agent)) { const autolinked = await autolink(cwd); if (autolinked) return; } + if (agent && !existing && !options.app) { + throwUsageError( + "Cannot select an application in agent mode. Pass --app , or run `clerk apps list --json` and retry.", + ); + } + intro("clerk link"); if (existing) { - const shouldRelink = await handleExistingProfile(existing, normalizedRemote, options); - if (!shouldRelink) { - outro(); - return; + if (agent) { + printExistingStatus(existing, normalizedRemote); + if (!options.app || !targetsDifferentApp) { + outro(); + return; + } + } else { + const shouldRelink = await handleExistingProfile(existing, normalizedRemote, options); + if (!shouldRelink) { + outro(); + return; + } } } diff --git a/packages/cli-core/src/commands/unlink/README.md b/packages/cli-core/src/commands/unlink/README.md index aff5b25d..d19f4ca6 100644 --- a/packages/cli-core/src/commands/unlink/README.md +++ b/packages/cli-core/src/commands/unlink/README.md @@ -24,4 +24,6 @@ clerk unlink --yes ## Agent Mode -In agent mode, outputs a structured prompt describing the unlink steps instead of running the interactive flow. +In agent mode, `clerk unlink` requires `--yes`. With `--yes`, it removes the +link without prompting. Without `--yes`, it exits with a usage error instead of +showing an interactive confirmation. diff --git a/packages/cli-core/src/commands/unlink/index.test.ts b/packages/cli-core/src/commands/unlink/index.test.ts index 3b550d28..a523c8ea 100644 --- a/packages/cli-core/src/commands/unlink/index.test.ts +++ b/packages/cli-core/src/commands/unlink/index.test.ts @@ -76,24 +76,28 @@ describe("unlink", () => { } describe("agent mode", () => { - test("outputs prompt and returns", async () => { + test("requires --yes", async () => { mockIsAgent.mockReturnValue(true); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await runUnlink(); + await expect(runUnlink()).rejects.toThrow("Pass --yes to unlink in agent mode."); - expect(captured.out).toContain("unlinking a Clerk application"); + expect(mockResolveProfile).not.toHaveBeenCalled(); + expect(mockRemoveProfile).not.toHaveBeenCalled(); }); - test("does not trigger side effects", async () => { + test("unlinks without prompting when --yes is passed", async () => { mockIsAgent.mockReturnValue(true); + mockIsHuman.mockReturnValue(false); + mockResolveProfile.mockResolvedValue(mockProfile); + mockRemoveProfile.mockResolvedValue(undefined); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await runUnlink(); + await runUnlink({ yes: true }); - expect(mockResolveProfile).not.toHaveBeenCalled(); - expect(mockRemoveProfile).not.toHaveBeenCalled(); expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockRemoveProfile).toHaveBeenCalledWith(process.cwd()); + expect(captured.out).toContain("Unlinked"); }); }); diff --git a/packages/cli-core/src/commands/unlink/index.ts b/packages/cli-core/src/commands/unlink/index.ts index 243c2ec4..a85d5851 100644 --- a/packages/cli-core/src/commands/unlink/index.ts +++ b/packages/cli-core/src/commands/unlink/index.ts @@ -3,32 +3,16 @@ import { isAgent, isHuman } from "../../mode.ts"; import { resolveProfile, removeProfile } from "../../lib/config.ts"; import { getGitRepoRoot } from "../../lib/git.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { CliError, ERROR_CODE, throwUserAbort } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -const AGENT_PROMPT = `You are unlinking a Clerk application from the current project directory. - -## Steps - -1. Resolve the current profile for the working directory using the config file at ~/.clerk/config.json. -2. If no profile is found, inform the user that the directory is not linked. -3. Remove the profile entry from ~/.clerk/config.json. - -## CLI Usage - -\`\`\` -clerk unlink # Interactive confirmation before unlinking -clerk unlink --yes # Skip confirmation -\`\`\``; - interface UnlinkOptions { yes?: boolean; } export async function unlink(options: UnlinkOptions = {}): Promise { - if (isAgent()) { - log.data(AGENT_PROMPT); - return; + if (isAgent() && !options.yes) { + throwUsageError("Pass --yes to unlink in agent mode."); } const cwd = process.cwd(); 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 44317946..bf7b4b60 100644 --- a/packages/cli-core/src/test/integration/agent-mode.test.ts +++ b/packages/cli-core/src/test/integration/agent-mode.test.ts @@ -1,10 +1,10 @@ /** * Agent mode provides actionable instructions - * AI agents get structured prompts instead of interactive flows. + * AI agents execute deterministic flows without interactive prompts. */ import { test, expect } from "bun:test"; -import { useIntegrationTestHarness, http, clerk } from "./lib/harness.ts"; +import { useIntegrationTestHarness, http, clerk, readConfig, MOCK_APP } from "./lib/harness.ts"; useIntegrationTestHarness(); @@ -14,16 +14,34 @@ test("init --prompt outputs structured agent prompt without API calls", async () expect(http.requests.length).toBe(0); }); -test("link outputs structured agent prompt without API calls", async () => { - const { stdout } = await clerk("--mode", "agent", "link"); - expect(stdout).toContain("linking a Clerk application"); - expect(stdout).toContain("## Steps"); - expect(http.requests.length).toBe(0); +test("link with --app writes the profile in agent mode", async () => { + http.mock({ + [`/applications/${MOCK_APP.application_id}`]: MOCK_APP, + }); + + await clerk("--mode", "agent", "link", "--app", MOCK_APP.application_id); + + const config = await readConfig(); + expect(config.profiles["github.com/test/project"]?.appId).toBe(MOCK_APP.application_id); + expect( + http.requests.some((r) => r.url.includes(`/applications/${MOCK_APP.application_id}`)), + ).toBe(true); }); -test("unlink outputs structured agent prompt without API calls", async () => { - const { stdout } = await clerk("--mode", "agent", "unlink"); - expect(stdout).toContain("unlinking a Clerk application"); - expect(stdout).toContain("## Steps"); - expect(http.requests.length).toBe(0); +test("unlink requires --yes in agent mode", async () => { + const result = await clerk.raw("--mode", "agent", "unlink"); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("Pass --yes to unlink in agent mode."); +}); + +test("unlink --yes removes the profile in agent mode", async () => { + http.mock({ + [`/applications/${MOCK_APP.application_id}`]: MOCK_APP, + }); + + await clerk("--mode", "agent", "link", "--app", MOCK_APP.application_id); + await clerk("--mode", "agent", "unlink", "--yes"); + + const config = await readConfig(); + expect(config.profiles["github.com/test/project"]).toBeUndefined(); }); diff --git a/packages/cli-core/src/test/integration/onboard.test.ts b/packages/cli-core/src/test/integration/onboard.test.ts index 864f4910..114ea0b7 100644 --- a/packages/cli-core/src/test/integration/onboard.test.ts +++ b/packages/cli-core/src/test/integration/onboard.test.ts @@ -3,10 +3,6 @@ * Tests the link -> env pull flow with framework-specific env var detection. */ -// TODO: Add agent mode coverage once `link` performs actual work in agent mode. -// Currently `link` in agent mode only prints a prompt without linking, so the -// full onboard flow (link -> env pull) cannot be tested in agent mode. - import { test, expect } from "bun:test"; import { join } from "node:path"; import { @@ -20,109 +16,107 @@ import { } from "./lib/harness.ts"; const h = useIntegrationTestHarness(); +const MODES = ["human", "agent"] as const; -test.each([ - { - framework: "Next.js", - deps: { next: "15.0.0" }, - expectedKey: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - { - framework: "React/Vite", - deps: { react: "19.0.0" }, - devDeps: { vite: "6.0.0" }, - expectedKey: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - { - framework: "Express", - deps: { express: "4.21.0" }, - expectedKey: "CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - { - framework: "Astro", - deps: { astro: "5.0.0" }, - expectedKey: "PUBLIC_CLERK_PUBLISHABLE_KEY", - envFile: ".env", - }, - { - framework: "Expo", - deps: { expo: "52.0.0" }, - expectedKey: "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - { - framework: "Nuxt", - deps: { nuxt: "3.0.0" }, - expectedKey: "NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY", - expectedSecretKey: "NUXT_CLERK_SECRET_KEY", - envFile: ".env", - }, - { - framework: "TanStack Start", - deps: { "@tanstack/react-start": "1.0.0", react: "19.0.0" }, - expectedKey: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - { - framework: "React Router", - deps: { "react-router": "7.0.0", react: "19.0.0" }, - expectedKey: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - { - framework: "Vue", - deps: { vue: "3.0.0" }, - expectedKey: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - { - framework: "Fastify", - deps: { fastify: "5.0.0" }, - expectedKey: "CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - { - framework: "No framework (fallback)", - deps: { lodash: "4.0.0" }, - expectedKey: "CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, -])("$framework", async ({ deps, devDeps, expectedKey, expectedSecretKey, envFile }) => { - const pkg = { - name: "test-project", - dependencies: deps, - ...(devDeps ? { devDependencies: devDeps } : {}), - }; - await Bun.write(join(h.tempDir, "package.json"), JSON.stringify(pkg)); +for (const mode of MODES) { + test.each([ + { + framework: "Next.js", + deps: { next: "15.0.0" }, + expectedKey: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + { + framework: "React/Vite", + deps: { react: "19.0.0" }, + devDeps: { vite: "6.0.0" }, + expectedKey: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + { + framework: "Express", + deps: { express: "4.21.0" }, + expectedKey: "CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + { + framework: "Astro", + deps: { astro: "5.0.0" }, + expectedKey: "PUBLIC_CLERK_PUBLISHABLE_KEY", + envFile: ".env", + }, + { + framework: "Expo", + deps: { expo: "52.0.0" }, + expectedKey: "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + { + framework: "Nuxt", + deps: { nuxt: "3.0.0" }, + expectedKey: "NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + expectedSecretKey: "NUXT_CLERK_SECRET_KEY", + envFile: ".env", + }, + { + framework: "TanStack Start", + deps: { "@tanstack/react-start": "1.0.0", react: "19.0.0" }, + expectedKey: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + { + framework: "React Router", + deps: { "react-router": "7.0.0", react: "19.0.0" }, + expectedKey: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + { + framework: "Vue", + deps: { vue: "3.0.0" }, + expectedKey: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + { + framework: "Fastify", + deps: { fastify: "5.0.0" }, + expectedKey: "CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + { + framework: "No framework (fallback)", + deps: { lodash: "4.0.0" }, + expectedKey: "CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + ])(`$framework (${mode})`, async ({ deps, devDeps, expectedKey, expectedSecretKey, envFile }) => { + const pkg = { + name: "test-project", + dependencies: deps, + ...(devDeps ? { devDependencies: devDeps } : {}), + }; + await Bun.write(join(h.tempDir, "package.json"), JSON.stringify(pkg)); - const devInstance = getInstance(MOCK_APP, "development"); + const devInstance = getInstance(MOCK_APP, "development"); - http.mock({ - [`/applications/${MOCK_APP.application_id}`]: MOCK_APP, - }); + http.mock({ + [`/applications/${MOCK_APP.application_id}`]: MOCK_APP, + }); - // Link to the app - await clerk("--mode", "human", "link", "--app", MOCK_APP.application_id); + await clerk("--mode", mode, "link", "--app", MOCK_APP.application_id); - // Verify config.json has profile keyed by git remote - const config = await readConfig(); - expect(config.profiles["github.com/test/project"]).toBeDefined(); - expect(config.profiles["github.com/test/project"]!.appId).toBe(MOCK_APP.application_id); + const config = await readConfig(); + expect(config.profiles["github.com/test/project"]).toBeDefined(); + expect(config.profiles["github.com/test/project"]!.appId).toBe(MOCK_APP.application_id); - // Pull env vars - await clerk("--mode", "human", "env", "pull"); + await clerk("--mode", mode, "env", "pull"); - // Verify env file has correct key=value pairs with no duplicates - const envContent = await Bun.file(join(h.tempDir, envFile)).text(); - const env = parseEnvFile(envContent, envFile); - const secretKey = expectedSecretKey ?? "CLERK_SECRET_KEY"; - expect(env.get(expectedKey)).toBe(devInstance.publishable_key); - expect(env.get(secretKey)).toBe(devInstance.secret_key); + const envContent = await Bun.file(join(h.tempDir, envFile)).text(); + const env = parseEnvFile(envContent, envFile); + const secretKey = expectedSecretKey ?? "CLERK_SECRET_KEY"; + expect(env.get(expectedKey)).toBe(devInstance.publishable_key); + expect(env.get(secretKey)).toBe(devInstance.secret_key); - // Verify Platform API calls included auth header - const plapiCalls = http.requests.filter((r) => r.url.includes("/applications/")); - expect(plapiCalls.length).toBeGreaterThan(0); -}); + const plapiCalls = http.requests.filter((r) => r.url.includes("/applications/")); + expect(plapiCalls.length).toBeGreaterThan(0); + }); +} diff --git a/packages/cli-core/src/test/integration/switch-apps.test.ts b/packages/cli-core/src/test/integration/switch-apps.test.ts index 2a0118bc..685f34e3 100644 --- a/packages/cli-core/src/test/integration/switch-apps.test.ts +++ b/packages/cli-core/src/test/integration/switch-apps.test.ts @@ -3,10 +3,6 @@ * Re-link from one app to another. */ -// TODO: Add agent mode coverage once `link` and `unlink` perform actual work -// in agent mode. Currently both commands only print prompts without modifying -// config, so the switch-apps flow cannot be tested in agent mode. - import { test, expect } from "bun:test"; import { join } from "node:path"; import { @@ -21,47 +17,45 @@ import { } from "./lib/harness.ts"; const h = useIntegrationTestHarness(); - -test("re-link from one app to another", async () => { - await Bun.write( - join(h.tempDir, "package.json"), - JSON.stringify({ name: "test", dependencies: { next: "15.0.0" } }), - ); - - const appADev = getInstance(MOCK_APP, "development"); - const appBDev = getInstance(MOCK_APP_B, "development"); - - // Link to App A - http.mock({ - [`/applications/${MOCK_APP.application_id}`]: MOCK_APP, +const MODES = ["human", "agent"] as const; + +for (const mode of MODES) { + test(`re-link from one app to another (${mode})`, async () => { + await Bun.write( + join(h.tempDir, "package.json"), + JSON.stringify({ name: "test", dependencies: { next: "15.0.0" } }), + ); + + const appADev = getInstance(MOCK_APP, "development"); + const appBDev = getInstance(MOCK_APP_B, "development"); + + http.mock({ + [`/applications/${MOCK_APP.application_id}`]: MOCK_APP, + }); + await clerk("--mode", mode, "link", "--app", MOCK_APP.application_id); + + let config = await readConfig(); + expect(config.profiles["github.com/test/project"]!.appId).toBe(MOCK_APP.application_id); + + await clerk("--mode", mode, "env", "pull"); + let env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); + expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(appADev.publishable_key); + + await clerk("--mode", mode, "unlink", "--yes"); + config = await readConfig(); + expect(config.profiles["github.com/test/project"]).toBeUndefined(); + + http.mock({ + [`/applications/${MOCK_APP_B.application_id}`]: MOCK_APP_B, + }); + await clerk("--mode", mode, "link", "--app", MOCK_APP_B.application_id); + + config = await readConfig(); + expect(config.profiles["github.com/test/project"]!.appId).toBe(MOCK_APP_B.application_id); + + await clerk("--mode", mode, "env", "pull"); + env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); + expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(appBDev.publishable_key); + expect(env.get("CLERK_SECRET_KEY")).toBe(appBDev.secret_key); }); - await clerk("--mode", "human", "link", "--app", MOCK_APP.application_id); - - let config = await readConfig(); - expect(config.profiles["github.com/test/project"]!.appId).toBe(MOCK_APP.application_id); - - // Pull env for App A - await clerk("--mode", "human", "env", "pull"); - let env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); - expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(appADev.publishable_key); - - // Unlink - await clerk("--mode", "human", "unlink", "--yes"); - config = await readConfig(); - expect(config.profiles["github.com/test/project"]).toBeUndefined(); - - // Link to App B - http.mock({ - [`/applications/${MOCK_APP_B.application_id}`]: MOCK_APP_B, - }); - await clerk("--mode", "human", "link", "--app", MOCK_APP_B.application_id); - - config = await readConfig(); - expect(config.profiles["github.com/test/project"]!.appId).toBe(MOCK_APP_B.application_id); - - // Pull env for App B — should overwrite App A's values, not append - await clerk("--mode", "human", "env", "pull"); - env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); - expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(appBDev.publishable_key); - expect(env.get("CLERK_SECRET_KEY")).toBe(appBDev.secret_key); -}); +} diff --git a/skills/clerk/SKILL.md b/skills/clerk/SKILL.md index 6e263358..2a69323f 100644 --- a/skills/clerk/SKILL.md +++ b/skills/clerk/SKILL.md @@ -108,6 +108,8 @@ clerk api /users --include clerk api /v1/platform/applications --platform ``` +For instance config, prefer the dedicated `clerk config ...` commands over raw Platform API `/config` paths. They handle dry-run, diffing, and confirmation more cleanly than the raw endpoint form. + **Always `--dry-run` a mutation before running it for real.** Then re-run without `--dry-run` (add `--yes` if you're sure). In agent mode, interactive confirmation is bypassed, so `--dry-run` is the only safety net for destructive calls. **JSON bodies must be valid JSON.** The CLI validates and rejects malformed payloads. @@ -118,28 +120,28 @@ See [references/recipes.md](references/recipes.md) for concrete patterns: listin ## Core commands at a glance -| Command | Purpose | Key flags | -| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `clerk init` | Scaffold Clerk into a project, or emit an agent handoff with `--prompt`. | `--framework`, `--pm`, `--name` (with `--starter`), `--app`, `--prompt`, `--starter`, `-y`, `--no-skills` | -| `clerk auth login` | OAuth browser login (stores token). Agent mode: no-op if already logged in, else prints guidance. Aliases: `signup`, `signin`, `sign-in`. Top-level shortcut: `clerk login`. | — | -| `clerk auth logout` | Clear stored credentials. Aliases: `signout`, `sign-out`. Top-level shortcut: `clerk logout`. | — | -| `clerk whoami` | Print the logged-in email. | — | -| `clerk link` | Link this repo to a Clerk app. | `--app ` | -| `clerk unlink` | Remove the link. | `--yes` | -| `clerk env pull` | Write publishable + secret keys to `.env.local` (merge, not clobber). | `--app`, `--instance`, `--file` | -| `clerk config pull` | Fetch instance config JSON. | `--app`, `--instance`, `--output`, `--keys` | -| `clerk config schema` | Fetch the JSON Schema for the instance config. | `--app`, `--instance`, `--output`, `--keys` | -| `clerk config patch` | Partial update (PATCH) of instance config. Pass `--destructive` to actually delete sub-resources touched by the patch rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | -| `clerk config put` | Full replacement (PUT) of instance config. Pass `--destructive` to actually delete removed sub-resources rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | -| `clerk apps list` | List Clerk applications. | `--json` | -| `clerk apps create ` | Create a new Clerk application. | `--json` | -| `clerk open [subpath]` | Open the linked app's dashboard in a browser. Agent mode: prints a JSON descriptor instead of opening. | `--print` | -| `clerk doctor` | Health check. | `--json`, `--spotlight`, `--verbose`, `--fix` | -| `clerk api [path]` | Authenticated HTTP to Backend/Platform API. | `-X`, `-d`, `--file`, `--dry-run`, `--yes`, `--include`, `--app`, `--secret-key`, `--instance`, `--platform` | -| `clerk api ls [filter]` | Discover endpoints from the bundled OpenAPI catalog. | `--platform` | -| `clerk completion [shell]` | Print a shell completion script (`bash`, `zsh`, `fish`, `powershell`). | — | -| `clerk update` | Update the CLI to the latest version. | `--channel`, `-y`, `--all` | -| `clerk skill install` | Reinstall the bundled `clerk` skill. Run after upgrading the CLI so the skill matches the new binary. | `-y`, `--pm` | +| Command | Purpose | Key flags | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `clerk init` | Scaffold Clerk into a project, or emit an agent handoff with `--prompt`. `--starter` only supports bootstrap for Next.js, React Router, Astro, Nuxt, TanStack Start, React, Vue, and JavaScript. | `--framework`, `--pm`, `--name` (with `--starter`), `--app`, `--prompt`, `--starter`, `-y`, `--no-skills` | +| `clerk auth login` | OAuth browser login (stores token). Agent mode: no-op if already logged in, else prints guidance. Aliases: `signup`, `signin`, `sign-in`. Top-level shortcut: `clerk login`. | — | +| `clerk auth logout` | Clear stored credentials. Aliases: `signout`, `sign-out`. Top-level shortcut: `clerk logout`. | — | +| `clerk whoami` | Print the logged-in email. | — | +| `clerk link` | Link this repo to a Clerk app. | `--app ` | +| `clerk unlink` | Remove the link. | `--yes` | +| `clerk env pull` | Write publishable + secret keys to `.env.local` (merge, not clobber). | `--app`, `--instance`, `--file` | +| `clerk config pull` | Fetch instance config JSON. | `--app`, `--instance`, `--output`, `--keys` | +| `clerk config schema` | Fetch the JSON Schema for the instance config. | `--app`, `--instance`, `--output`, `--keys` | +| `clerk config patch` | Partial update (PATCH) of instance config. Pass `--destructive` to actually delete sub-resources touched by the patch rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | +| `clerk config put` | Full replacement (PUT) of instance config. Pass `--destructive` to actually delete removed sub-resources rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | +| `clerk apps list` | List Clerk applications. | `--json` | +| `clerk apps create ` | Create a new Clerk application. | `--json` | +| `clerk open [subpath]` | Open the linked app's dashboard in a browser. Agent mode: prints a JSON descriptor instead of opening. | `--print` | +| `clerk doctor` | Health check. | `--json`, `--spotlight`, `--verbose`, `--fix` | +| `clerk api [path]` | Authenticated HTTP to Backend/Platform API. | `-X`, `-d`, `--file`, `--dry-run`, `--yes`, `--include`, `--app`, `--secret-key`, `--instance`, `--platform` | +| `clerk api ls [filter]` | Discover endpoints from the bundled OpenAPI catalog. | `--platform` | +| `clerk completion [shell]` | Print a shell completion script (`bash`, `zsh`, `fish`, `powershell`). | — | +| `clerk update` | Update the CLI to the latest version. | `--channel`, `-y`, `--all` | +| `clerk skill install` | Reinstall the bundled `clerk` skill. Run after upgrading the CLI so the skill matches the new binary. | `-y`, `--pm` | **`clerk --help` is the source of truth for flags.** This table is a hint, not a spec. Before running an unfamiliar command or flag combination, run `clerk --help` once per session. Every command also defines `setExamples([...])` in source, which `--help` renders as a copy-pasteable Examples block, so you rarely need to guess syntax. @@ -147,7 +149,9 @@ See [references/recipes.md](references/recipes.md) for concrete patterns: listin The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` / `CLERK_MODE=agent` is set. In agent mode: -- **Interactive prompts are disabled.** Commands that would normally show pickers (`link` without `--app`, interactive `api`, `unlink` without `--yes`) either auto-resolve, print structured guidance, or exit. Always pass explicit flags (`--app`, `--yes`) in scripted calls. +- **Interactive prompts are disabled.** Commands that would normally show pickers (`link` without `--app`, interactive `api`, `unlink` without `--yes`) either auto-resolve or exit with a usage error. Always pass explicit flags (`--app`, `--yes`) in scripted calls. +- **`link` supports deterministic agent flows.** In agent mode, `clerk link --app ` links directly. Without `--app`, the CLI will try silent key-based autolink first; if it cannot determine the app unambiguously, it exits and tells you to pass `--app`. +- **`unlink` requires `--yes` in agent mode.** This preserves the same safety bar as other destructive commands while still letting an agent complete the unlink non-interactively. - **Mutations still require `--yes`** unless you accept per-call confirmation is impossible. - **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. - **`apps list` and `apps create` default to JSON** when piped. diff --git a/skills/clerk/references/agent-mode.md b/skills/clerk/references/agent-mode.md index d07ed71c..71346d70 100644 --- a/skills/clerk/references/agent-mode.md +++ b/skills/clerk/references/agent-mode.md @@ -17,6 +17,8 @@ Force human mode with `--mode human` or `CLERK_MODE=human`. Typical AI-agent inv | Behavior | Human mode | Agent mode | | ---------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Interactive pickers (`link` without `--app`, `api` with no args) | Show a TUI picker | Print structured guidance and exit, or auto-resolve | +| `clerk link --app ` | Links directly | Links directly | +| `clerk link` without `--app` | Interactive picker / create UI | Tries silent autolink from detected publishable keys; if no deterministic match exists, exits with a usage error telling the caller to pass `--app` | | Confirmation prompts (`unlink`, `config patch`, `api -X DELETE`) | Prompt y/n | Require `--yes`, otherwise error | | `clerk doctor --fix` | Interactively offers fixes | **Ignored**; output the `remedy` field and let the caller act | | `clerk apps list` default output | Table | JSON (when piped) | @@ -101,6 +103,8 @@ clerk api /users/user_abc123 -X DELETE --yes clerk api /users --app app_abc123 --instance prod ``` +The same advice applies to linking in agent mode: `clerk link --app app_abc123` is deterministic and works non-interactively. If you omit `--app`, the command only succeeds when silent autolink can prove the target app from existing publishable keys. + ### Use the catalog, not hard-coded paths ```sh @@ -126,12 +130,13 @@ When `clerk doctor --json` reports a failure, show the user the `name`, `message | `CLI version` | (no auto-fix; run `clerk update`) | | `Shell completion` | (no auto-fix; see `clerk completion --help`) | -All three remediation commands are themselves interactive by default: `auth login` opens a browser, `link` prompts for an app when `--app` is omitted, and `env pull` writes a file. Prefer surfacing the mapping to the user rather than invoking these blindly. +All three remediation commands are themselves interactive by default: `auth login` opens a browser, `link` prompts for an app when `--app` is omitted, and `env pull` writes a file. In agent mode, prefer `clerk link --app ` over bare `clerk link`, since the bare form only works when silent autolink can resolve the target app without a picker. ## What NOT to do in agent mode - **Don't call `clerk auth login` from an agent and expect it to work** — it opens a browser and waits for a callback. Instead, export `CLERK_PLATFORM_API_KEY`. -- **Don't call interactive `clerk link` without `--app`** — it will print guidance, not pick an app. +- **Don't call `clerk link` without `--app` and assume the agent can pick for you** — it only succeeds when silent autolink can determine the app from detected keys. +- **Don't run `clerk unlink` in agent mode without `--yes`** — it exits with a usage error instead of prompting. - **Don't run `clerk config put` without `--dry-run` first** — it's a full replacement and is destructive. - **Don't skip `--yes` on mutations and expect them to work** — agent mode disables prompts, so commands that require confirmation will error. - **Don't leak secret keys into logs** — the CLI never prints the raw secret key, and you shouldn't either. diff --git a/skills/clerk/references/recipes.md b/skills/clerk/references/recipes.md index b0f2eb49..6560bbc9 100644 --- a/skills/clerk/references/recipes.md +++ b/skills/clerk/references/recipes.md @@ -78,6 +78,17 @@ clerk api /organizations/org_abc123/memberships/user_xyz -X DELETE --dry-run clerk api /organizations/org_abc123/invitations -d '{"email_address":"new@acme.com","role":"org:member"}' ``` +If organization endpoints return `organization_not_enabled_in_instance`, enable the feature first: + +```sh +# Inspect org settings +clerk api /instance/organization_settings + +# Preview, then enable organizations for this instance +clerk api /instance/organization_settings -X PATCH -d '{"enabled":true}' --dry-run +clerk api /instance/organization_settings -X PATCH -d '{"enabled":true}' --yes +``` + ## Sessions ```sh From a6f55e5628f33fbf28e28f81fdef71402ecf6322 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 22 Apr 2026 11:57:37 -0600 Subject: [PATCH 2/3] chore(release): add changeset for agent link fixes --- .changeset/agent-link-unlink.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/agent-link-unlink.md diff --git a/.changeset/agent-link-unlink.md b/.changeset/agent-link-unlink.md new file mode 100644 index 00000000..fefd6874 --- /dev/null +++ b/.changeset/agent-link-unlink.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Fix agent-mode linking flows. `clerk link --app ` now works non-interactively in agent mode, `clerk link` without `--app` tries deterministic autolink before failing with a usage error, and `clerk unlink --yes` now unlinks instead of printing guidance. The bundled `skills/clerk` docs were updated to match the new agent-mode behavior. From 0644ff1256d3f27e94f87735dbe0d0db8165405d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 22 Apr 2026 12:07:43 -0600 Subject: [PATCH 3/3] refactor(link): simplify existing agent branch --- packages/cli-core/src/commands/link/index.ts | 24 +++++++++----------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index daaa17c9..a6c20bcf 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -68,19 +68,17 @@ export async function link(options: LinkOptions = {}): Promise { intro("clerk link"); - if (existing) { - if (agent) { - printExistingStatus(existing, normalizedRemote); - if (!options.app || !targetsDifferentApp) { - outro(); - return; - } - } else { - const shouldRelink = await handleExistingProfile(existing, normalizedRemote, options); - if (!shouldRelink) { - outro(); - return; - } + if (existing && agent) { + printExistingStatus(existing, normalizedRemote); + if (!targetsDifferentApp) { + outro(); + return; + } + } else if (existing) { + const shouldRelink = await handleExistingProfile(existing, normalizedRemote, options); + if (!shouldRelink) { + outro(); + return; } }