Skip to content
Open
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/whoami-linked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Show the linked Clerk application in `clerk whoami` output, and add a `--json` flag that emits a structured payload covering email and link state.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Commands:
auth Manage authentication
link [options] Link this project to a Clerk application
unlink [options] Unlink this project from its Clerk application
whoami Show the current logged-in user
whoami [options] Show the current logged-in user and linked application
open Open Clerk resources in your browser
apps Manage your Clerk applications
users [options] Manage Clerk users
Expand Down
10 changes: 7 additions & 3 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,13 @@ Give AI agents better Clerk context: install the Clerk skills

program
.command("whoami")
.description("Show the current logged-in user")
.setExamples([{ command: "clerk whoami", description: "Show your email address" }])
.action(whoami);
.description("Show the current logged-in user and linked application")
.option("--json", "Output JSON")
.setExamples([
{ command: "clerk whoami", description: "Show your email and linked app" },
{ command: "clerk whoami --json", description: "Emit a structured payload on stdout" },
])
.action((options) => whoami({ json: options.json }));

const open = program.command("open").description("Open Clerk resources in your browser");

Expand Down
43 changes: 38 additions & 5 deletions packages/cli-core/src/commands/whoami/README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,52 @@
# Whoami Command

Displays the email address of the currently authenticated user.
Displays the email address of the currently authenticated user, plus the Clerk application this directory is linked to (if any).

## Usage

```sh
clerk whoami
clerk whoami --json
```

## Options

| Option | Description |
| -------- | ----------------------------------------------------------- |
| `--json` | Emit a structured payload on stdout; suppresses next-steps. |

## Behavior

- Reads the stored authentication token from the local credential store
- Fetches user info from the Clerk API and prints the user's email
- If no token exists, prints a message to run `clerk auth login`
- If the token is expired or invalid, prints a session expired message
- Reads the stored authentication token from the local credential store.
- Fetches user info from the Clerk API and prints the user's email to **stdout**.
- Calls `resolveProfile(cwd)` (best-effort — failures are swallowed) to determine whether the working directory is linked to a Clerk application.
- When linked, prints a `Linked to ...` line on **stderr** above the next-steps, where `...` is the app label rendered by `profileLabel()` from `lib/config.ts` — for example, `Linked to MyApp (app_xxx)`.
- When not linked, only the existing `WHOAMI` next-steps are printed.
- If no token exists, throws an `AuthError` ("Not logged in").
- If the token is expired or invalid, throws an `AuthError` ("Session expired").

### `--json` (and agent mode)

When `--json` is passed, or when the CLI is in agent mode (`isAgent()`), `whoami` emits a single JSON object on stdout and skips human next-steps:

```json
{
"email": "alice@example.com",
"linked": {
"appId": "app_xxx",
"appName": "MyApp",
"instances": { "development": "ins_dev_xxx", "production": "ins_prod_xxx" },
"resolvedVia": "remote",
"path": "github.com/clerk/cli"
}
}
```

`linked` is `null` when the directory is not linked or when profile resolution fails. Optional fields (`appName`, `instances.production`) are normalized to `null` rather than omitted.

## Pipe contract

Human-mode stdout is the email and only the email — `clerk whoami | grep @` continues to work. The link line and next-steps are stderr. JSON mode replaces the email-only stdout with the full payload above.

## API Endpoints

Expand Down
158 changes: 155 additions & 3 deletions packages/cli-core/src/commands/whoami/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { captureLog, credentialStoreStubs, tokenExchangeStubs } from "../../test/lib/stubs.ts";
import {
captureLog,
configStubs,
credentialStoreStubs,
tokenExchangeStubs,
} from "../../test/lib/stubs.ts";
import { CliError } from "../../lib/errors.ts";

const mockGetValidToken = mock();
const mockFetchUserInfo = mock();
const mockResolveProfile = mock();
const mockIsAgent = mock();

mock.module("../../lib/credential-store.ts", () => ({
...credentialStoreStubs,
Expand All @@ -15,25 +22,52 @@ mock.module("../../lib/token-exchange.ts", () => ({
fetchUserInfo: (...args: unknown[]) => mockFetchUserInfo(...args),
}));

mock.module("../../lib/config.ts", () => ({
...configStubs,
resolveProfile: (...args: unknown[]) => mockResolveProfile(...args),
}));

mock.module("../../mode.ts", () => ({
isAgent: (...args: unknown[]) => mockIsAgent(...args),
isHuman: (...args: unknown[]) => !mockIsAgent(...args),
setMode: () => {},
getMode: () => (mockIsAgent() ? "agent" : "human"),
}));

const { whoami } = await import("./index.ts");

const linkedProfile = {
path: "github.com/clerk/cli",
profile: {
workspaceId: "ws_123",
appId: "app_xxx",
appName: "MyApp",
instances: { development: "ins_dev_xxx", production: "ins_prod_xxx" },
},
resolvedVia: "remote" as const,
};

describe("whoami", () => {
let consoleSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;

beforeEach(() => {
captured = captureLog();
mockIsAgent.mockReturnValue(false);
mockResolveProfile.mockResolvedValue(undefined);
});

afterEach(() => {
captured.teardown();
mockGetValidToken.mockReset();
mockFetchUserInfo.mockReset();
mockResolveProfile.mockReset();
mockIsAgent.mockReset();
consoleSpy?.mockRestore();
});

function runWhoami() {
return captured.run(() => whoami());
function runWhoami(options?: { json?: boolean }) {
return captured.run(() => whoami(options));
}

test("prints email when authenticated", async () => {
Expand Down Expand Up @@ -66,4 +100,122 @@ describe("whoami", () => {
await expect(captured.run(() => whoami())).rejects.toThrow(/Session expired/);
expect(captured.out).toBe("");
});

test("prints linked app label on stderr when linked", async () => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" });
mockResolveProfile.mockResolvedValue(linkedProfile);

await runWhoami();

expect(captured.out.trim()).toBe("alice@example.com");
expect(captured.err).toContain("Linked to");
expect(captured.err).toContain("MyApp (app_xxx)");
});

test("falls back to appId when appName is missing", async () => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" });
mockResolveProfile.mockResolvedValue({
...linkedProfile,
profile: { ...linkedProfile.profile, appName: undefined },
});

await runWhoami();

expect(captured.err).toContain("Linked to");
expect(captured.err).toContain("app_xxx");
expect(captured.err).not.toContain("MyApp");
});

test("omits linked line when not linked", async () => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" });
mockResolveProfile.mockResolvedValue(undefined);

await runWhoami();

expect(captured.out.trim()).toBe("alice@example.com");
expect(captured.err).not.toContain("Linked to");
});

test("omits linked line when resolveProfile throws (best-effort)", async () => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" });
mockResolveProfile.mockRejectedValue(new Error("git failed"));

await runWhoami();

expect(captured.out.trim()).toBe("alice@example.com");
expect(captured.err).not.toContain("Linked to");
});

test("--json emits structured payload with linked details and suppresses next-steps", async () => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" });
mockResolveProfile.mockResolvedValue(linkedProfile);

await runWhoami({ json: true });

const payload = JSON.parse(captured.out);
expect(payload).toEqual({
email: "alice@example.com",
linked: {
appId: "app_xxx",
appName: "MyApp",
instances: { development: "ins_dev_xxx", production: "ins_prod_xxx" },
resolvedVia: "remote",
path: "github.com/clerk/cli",
},
});
expect(captured.err).not.toContain("→");
expect(captured.err).not.toContain("Linked to");
});

test("--json sets linked to null when not linked", async () => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" });
mockResolveProfile.mockResolvedValue(undefined);

await runWhoami({ json: true });

expect(JSON.parse(captured.out)).toEqual({
email: "alice@example.com",
linked: null,
});
});

test("--json normalizes missing optional fields to null", async () => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" });
mockResolveProfile.mockResolvedValue({
...linkedProfile,
profile: {
...linkedProfile.profile,
appName: undefined,
instances: { development: "ins_dev_xxx" },
},
});

await runWhoami({ json: true });

expect(JSON.parse(captured.out).linked).toMatchObject({
appName: null,
instances: { development: "ins_dev_xxx", production: null },
});
});

test("agent mode emits JSON without --json flag", async () => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" });
mockResolveProfile.mockResolvedValue(linkedProfile);
mockIsAgent.mockReturnValue(true);

await runWhoami();

const payload = JSON.parse(captured.out);
expect(payload.email).toBe("alice@example.com");
expect(payload.linked.appId).toBe("app_xxx");
expect(captured.err).not.toContain("Linked to");
});
});
47 changes: 41 additions & 6 deletions packages/cli-core/src/commands/whoami/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { withSpinner } from "../../lib/spinner.ts";
import { log } from "../../lib/log.ts";
import { AuthError } from "../../lib/errors.ts";
import { resolveProfile } from "../../lib/config.ts";
import { profileLabel, resolveProfile } from "../../lib/config.ts";
import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts";
import { isAgent } from "../../mode.ts";

export async function whoami() {
export interface WhoamiOptions {
json?: boolean;
}

export async function whoami(options: WhoamiOptions = {}) {
const token = await getValidToken();
if (!token) {
throw new AuthError({ reason: "not_logged_in" });
Expand All @@ -18,13 +23,43 @@ export async function whoami() {
} catch {
throw new AuthError({ reason: "session_expired" });
}
log.data(userInfo.email);

let isLinked = false;
let resolved: Awaited<ReturnType<typeof resolveProfile>>;
try {
isLinked = Boolean(await resolveProfile(process.cwd()));
resolved = await resolveProfile(process.cwd());
} catch {
// Best-effort only: don't fail whoami when local profile resolution fails.
resolved = undefined;
}

if (options.json || isAgent()) {
log.data(
JSON.stringify(
{
email: userInfo.email,
linked: resolved
? {
appId: resolved.profile.appId,
appName: resolved.profile.appName ?? null,
instances: {
development: resolved.profile.instances.development,
production: resolved.profile.instances.production ?? null,
},
resolvedVia: resolved.resolvedVia,
path: resolved.path,
}
: null,
},
null,
2,
),
);
return;
}

log.data(userInfo.email);
if (resolved) {
log.info(`Linked to \`${profileLabel(resolved.profile)}\``);
}
printNextSteps(isLinked ? NEXT_STEPS.WHOAMI_LINKED : NEXT_STEPS.WHOAMI);
printNextSteps(resolved ? NEXT_STEPS.WHOAMI_LINKED : NEXT_STEPS.WHOAMI);
}
4 changes: 4 additions & 0 deletions packages/cli-core/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ interface Profile {
};
}

export function profileLabel(profile: Profile): string {
return profile.appName ? `${profile.appName} (${profile.appId})` : profile.appId;
}

interface ClerkConfig {
environment?: string;
auth?: Record<string, Auth>;
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-core/src/test/lib/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export const configStubs = {
resolveProfileOrAutolink: noop,
resolveInstanceId: () => ({ id: "", label: "" }),
resolveAppContext: async () => ({ appId: "", appLabel: "", instanceId: "", instanceLabel: "" }),
profileLabel: (profile: { appName?: string; appId: string }) =>
profile.appName ? `${profile.appName} (${profile.appId})` : profile.appId,
};

export const autolinkStubs = {
Expand Down