diff --git a/.changeset/whoami-linked.md b/.changeset/whoami-linked.md new file mode 100644 index 00000000..e2f71302 --- /dev/null +++ b/.changeset/whoami-linked.md @@ -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. diff --git a/README.md b/README.md index 14a286de..7e8be625 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a6d736fb..21b3954b 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -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"); diff --git a/packages/cli-core/src/commands/whoami/README.md b/packages/cli-core/src/commands/whoami/README.md index 42e48468..b1909869 100644 --- a/packages/cli-core/src/commands/whoami/README.md +++ b/packages/cli-core/src/commands/whoami/README.md @@ -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 diff --git a/packages/cli-core/src/commands/whoami/index.test.ts b/packages/cli-core/src/commands/whoami/index.test.ts index d14a416f..2f7e3625 100644 --- a/packages/cli-core/src/commands/whoami/index.test.ts +++ b/packages/cli-core/src/commands/whoami/index.test.ts @@ -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, @@ -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; let captured: ReturnType; 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 () => { @@ -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"); + }); }); diff --git a/packages/cli-core/src/commands/whoami/index.ts b/packages/cli-core/src/commands/whoami/index.ts index 0e20b452..5bcfa9ac 100644 --- a/packages/cli-core/src/commands/whoami/index.ts +++ b/packages/cli-core/src/commands/whoami/index.ts @@ -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" }); @@ -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>; 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); } diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 289ce5ab..9dd85de6 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -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; diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index 93faff37..ec54abfc 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -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 = {