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

Fix agent-mode linking flows. `clerk link --app <id>` 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.
12 changes: 8 additions & 4 deletions packages/cli-core/src/commands/link/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <id>`
- For non-interactive/CI/agent flows, create apps from the Clerk Dashboard or via the Platform API, then pass `--app <id>`
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

Expand Down
62 changes: 55 additions & 7 deletions packages/cli-core/src/commands/link/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 <id>",
);

expect(mockSearch).not.toHaveBeenCalled();
expect(mockListApplications).not.toHaveBeenCalled();
});
});

Expand Down
49 changes: 22 additions & 27 deletions packages/cli-core/src/commands/link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app_id>\`
- 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 {
Expand All @@ -52,11 +39,7 @@ function appLabel(app: Application): string {
}

export async function link(options: LinkOptions = {}): Promise<void> {
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);
Expand All @@ -72,14 +55,26 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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 <id>, or run `clerk apps list --json` and retry.",
);
}

intro("clerk link");

if (existing) {
if (existing && agent) {
printExistingStatus(existing, normalizedRemote);
if (!targetsDifferentApp) {
outro();
return;
}
} else if (existing) {
const shouldRelink = await handleExistingProfile(existing, normalizedRemote, options);
if (!shouldRelink) {
outro();
Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/unlink/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 11 additions & 7 deletions packages/cli-core/src/commands/unlink/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand Down
22 changes: 3 additions & 19 deletions packages/cli-core/src/commands/unlink/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (isAgent()) {
log.data(AGENT_PROMPT);
return;
if (isAgent() && !options.yes) {
throwUsageError("Pass --yes to unlink in agent mode.");
}

const cwd = process.cwd();
Expand Down
42 changes: 30 additions & 12 deletions packages/cli-core/src/test/integration/agent-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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();
});
Loading
Loading