Skip to content

Instantly share code, notes, and snippets.

@ftuyama
Last active May 4, 2026 16:59
Show Gist options
  • Select an option

  • Save ftuyama/eb3c40c4b507b456faae967f5678ad93 to your computer and use it in GitHub Desktop.

Select an option

Save ftuyama/eb3c40c4b507b456faae967f5678ad93 to your computer and use it in GitHub Desktop.
Switch Cursor Account
description Switch Cursor web session between two Google accounts (agents page, menu, logout, Google sign-in)

Switch Cursor web account (Playwright MCP)

Use the MCP Playwright Brave server (user-MCP Playwright Brave). If MCP calls fail with a missing Playwright bridge extension, tell the user.

Accounts (toggle)

Account
Personal personal@gmail.com
Work work@gmail.com

Goal: if the session is one of these, end on the other. After Sign inContinue with Google, click the Google picker row for the target email (not the one currently signed in).

Goal (browser steps)

  1. Open https://cursor.com/agents
  2. Open the bottom-left overflow menu (three dots / User menu)
  3. Read Signed in as and note the current email (personal@gmail.com or work@gmail.com). If it matches neither, say what you saw and ask how to proceed.
  4. Target = the other account in the pair above. If already on the target, stop unless the user wants a full re-login.
  5. Log out
  6. Click Sign in
  7. Click Continue with Google
  8. In Google’s account picker, choose the row for the target email (complete any extra Google prompts manually if automation stops)
  9. Confirm on Cursor: Signed in as shows the target email.

Tool sequence

Use these MCP tools in order; after each navigation or menu open, take browser_snapshot and click using the target ref from the latest snapshot (refs change per page).

  1. browser_navigateurl: https://cursor.com/agents
  2. browser_wait_for — wait until the page shows stable UI (e.g. time: 2 or text matching a known string from the agents page)
  3. browser_snapshot — locate the three-dots / User menu in the lower-left; browser_click with that target
  4. browser_snapshot — verify copy like Signed in as; compute target = the other of personal@gmail.com vs work@gmail.com. If already on target, say so and stop unless the user wants a full re-login.
  5. browser_clickLog out (or Log Out) from the menu or subsequent UI
  6. browser_wait_for — until Sign in (or Sign In) is visible
  7. browser_clickSign in / Sign In
  8. browser_clickContinue with Google (or Google)
  9. If a new tab opens for Google, use browser_tabs (action: list, then select with the Google tab index) and browser_snapshot again.
  10. browser_click the row or button for the target email (personal@gmail.com or work@gmail.com, whichever is not the session you saw in step 4). If the picker is inside a cross-origin iframe and clicks fail, use browser_run_code_unsafe only if the user approves, or ask them to pick the account manually in the browser window.
  11. browser_wait_for — until cursor.com shows the new session; browser_snapshot to confirm Signed in as matches the target email.

Security

Do not paste passwords or session tokens into chat. OAuth steps that require password or 2FA must be completed by the user in the browser.

/**
* Switch Cursor web session (Google accounts)
*
* Prerequisites:
* npx -y playwright install chromium
* Enable brave://inspect/#remote-debugging
*
* Run:
* export CURSOR_ACCOUNTS='work@gmail.com,personal@gmail.com'
* npx -y -p playwright -p tsx tsx .cursor/switch-cursor-account.ts
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { chromium, type BrowserContext, type Page } from 'playwright';
const AGENTS_URL = 'https://cursor.com/agents';
const DASHBOARD_URL = 'https://cursor.com/dashboard';
const DEFAULT_BRAVE_PATH = '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser';
const TIMEOUT_MS = 30_000;
const BRAVE_ARGS_COMMON = [
'--no-first-run',
'--no-default-browser-check',
'--disable-infobars',
'--disable-notifications',
] as const;
function defaultBraveUserDataDir(): string {
const home = os.homedir();
if (process.platform === 'darwin') {
return path.join(home, 'Library/Application Support/BraveSoftware/Brave-Browser');
}
if (process.platform === 'win32') {
return path.join(home, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser');
}
return path.join(home, '.config', 'BraveSoftware', 'Brave-Browser');
}
function parseAccountPair(): [string, string] {
const raw = process.env.CURSOR_ACCOUNTS?.trim();
if (!raw) {
throw new Error(
'Set CURSOR_ACCOUNTS to exactly two comma-separated emails, e.g. CURSOR_ACCOUNTS=work@corp.com,personal@example.com',
);
}
const parts = raw
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
if (parts.length !== 2) {
throw new Error(
'CURSOR_ACCOUNTS must list exactly two emails: work@corp.com,personal@gmail.com',
);
}
return [parts[0], parts[1]];
}
function resolveTargetEmail(current: string | null, pair: [string, string]): string {
const [a, b] = pair;
if (current === a) return b;
if (current === b) return a;
console.warn(
`Session is not ${a} or ${b} (got: ${current ?? 'unknown'}) — signing in as ${a}.`,
);
return a;
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function openUserMenu(page: Page): Promise<void> {
await page.getByRole('button', { name: 'User menu' }).click();
}
/**
* First match for email-shaped text in the menu. TLD segment is greedy, so adjacent
* lowercase UI words (e.g. "upgrade" glued after .com) can be absorbed — strip those after.
*/
const EMAIL_IN_TEXT =
/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63})(?![a-zA-Z0-9._%+])/;
/** Cursor user menu can concatenate tier/CTA labels with no separator after the email. */
const GLUED_MENU_WORD = /(upgrade|pro|free|plus|business)$/i;
function looksLikeEmail(s: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s);
}
function stripGluedMenuWordsAfterEmail(email: string): string {
let out = email;
while (GLUED_MENU_WORD.test(out)) {
const next = out.replace(GLUED_MENU_WORD, '');
if (!looksLikeEmail(next)) break;
out = next;
}
return out;
}
async function readSignedInEmailFromMenu(page: Page): Promise<string | null> {
const menu = page.getByRole('menu', { name: 'User menu' });
const raw = await menu.textContent().catch(() => null);
if (!raw) return null;
const after = raw.split(/signed in as/i)[1] ?? raw;
const m = after.match(EMAIL_IN_TEXT);
const extracted = m?.[1]?.toLowerCase() ?? null;
if (!extracted) return null;
return stripGluedMenuWordsAfterEmail(extracted);
}
async function assertSignedInAs(page: Page, email: string): Promise<void> {
await page.goto(DASHBOARD_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
await openUserMenu(page);
const seen = await readSignedInEmailFromMenu(page);
if (seen !== email.toLowerCase()) {
throw new Error(
`Expected Signed in as ${email}, got ${seen ?? '(could not read menu)'}`,
);
}
console.log(`OK — Signed in as ${email}`);
}
async function pickGooglePageAfterOAuthClick(page: Page): Promise<Page> {
const popupPromise = page.waitForEvent('popup', { timeout: 5000 });
await page.getByRole('link', { name: /Continue with Google/i }).click();
try {
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
return popup;
} catch {
await page.waitForURL(/accounts\.google\.com/, { timeout: TIMEOUT_MS });
return page;
}
}
// --- CDP (attach to running Brave) ---
type DevToolsPortFile = {
port: number;
httpBase: string;
wsBrowser?: string;
};
function parseDevToolsActivePort(raw: string): DevToolsPortFile | null {
const lines = raw
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const port = Number.parseInt(lines[0] ?? '', 10);
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
const httpBase = `http://127.0.0.1:${port}`;
const line2 = lines[1];
const wsBrowser =
line2?.startsWith('/') && line2.includes('devtools')
? `ws://127.0.0.1:${port}${line2}`
: undefined;
return { port, httpBase, wsBrowser };
}
async function readDevToolsActivePort(userDataDir: string): Promise<DevToolsPortFile | null> {
try {
const raw = await readFile(path.join(userDataDir, 'DevToolsActivePort'), 'utf8');
return parseDevToolsActivePort(raw);
} catch {
return null;
}
}
function portFromHttpUrl(httpBase: string): number | null {
try {
const u = new URL(httpBase.includes('://') ? httpBase : `http://${httpBase}`);
const p = Number(u.port || (u.protocol === 'https:' ? 443 : 80));
return Number.isFinite(p) && p > 0 ? p : null;
} catch {
return null;
}
}
/** If HTTP /json/version fails but DevToolsActivePort matches, use browser WebSocket (Brave). */
function matchingWsFallback(
file: DevToolsPortFile | null,
httpPort: number | null,
): string | undefined {
if (file?.wsBrowser && httpPort != null && file.port === httpPort) {
return file.wsBrowser;
}
return undefined;
}
async function normalizeCdpEndpoint(httpOrWs: string): Promise<string> {
const trimmed = httpOrWs.trim();
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
return trimmed;
}
const base = trimmed.replace(/\/$/, '');
const file = await readDevToolsActivePort(defaultBraveUserDataDir());
const port = portFromHttpUrl(base);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 8000);
let res: Response;
try {
res = await fetch(`${base}/json/version`, { signal: ac.signal });
} catch {
const ws = matchingWsFallback(file, port);
if (ws) return ws;
throw new Error(cdpBadEndpointHint(base));
} finally {
clearTimeout(timer);
}
if (!res.ok) {
const ws = matchingWsFallback(file, port);
if (ws) return ws;
throw new Error(cdpBadEndpointHint(base));
}
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
return j.webSocketDebuggerUrl ?? base;
}
function cdpRefusedHint(endpoint: string): string {
return [
`CDP connect failed (${endpoint}): nothing listening — open Brave and enable brave://inspect/#remote-debugging.`,
'Or delete stale DevToolsActivePort if Brave is closed.',
].join('\n');
}
function cdpBadEndpointHint(tried: string): string {
const profile = defaultBraveUserDataDir();
const port = portFromHttpUrl(tried);
const lsof =
port != null
? `lsof -nP -iTCP:${port}`
: 'lsof -nP -iTCP:9222';
return [
`CDP failed (${tried}): not a DevTools HTTP endpoint.`,
`Check: ${lsof} • cat "${path.join(profile, 'DevToolsActivePort')}"`,
'Or ensure Brave remote debugging matches that file.',
].join('\n');
}
function cdpConnectError(err: unknown, endpoint: string): Error {
if (!(err instanceof Error)) return new Error(String(err));
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ECONNREFUSED' || /ECONNREFUSED/i.test(err.message)) {
return new Error(cdpRefusedHint(endpoint));
}
if (/404|does not look like a DevTools/i.test(err.message)) {
return new Error(cdpBadEndpointHint(endpoint));
}
return err;
}
async function connectBrowser(): Promise<{
context: BrowserContext;
dispose: () => Promise<void>;
}> {
const devtools = await readDevToolsActivePort(defaultBraveUserDataDir());
if (devtools) {
const cdpRaw = devtools.wsBrowser ?? devtools.httpBase;
console.log(
`CDP: ${cdpRaw}${devtools.wsBrowser ? ' (ws from DevToolsActivePort)' : ''}`,
);
const connectUrl = await normalizeCdpEndpoint(cdpRaw);
try {
const browser = await chromium.connectOverCDP(connectUrl);
const context = browser.contexts()[0] ?? (await browser.newContext());
return { context, dispose: () => browser.close() };
} catch (err) {
throw cdpConnectError(err, cdpRaw);
}
}
const browser = await chromium.launch({
executablePath: DEFAULT_BRAVE_PATH,
args: [
...BRAVE_ARGS_COMMON,
'--disable-default-apps',
'--disable-blink-features=AutomationControlled',
],
});
const context = await browser.newContext();
return { context, dispose: () => browser.close() };
}
async function main(): Promise<void> {
const pair = parseAccountPair();
const { context, dispose } = await connectBrowser();
const page = await context.newPage();
page.setDefaultTimeout(TIMEOUT_MS);
try {
console.log(`→ ${AGENTS_URL}`);
await page.goto(AGENTS_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
await openUserMenu(page);
const current = await readSignedInEmailFromMenu(page);
console.log(`Signed in as: ${current ?? '(unknown)'}`);
const target = resolveTargetEmail(current, pair);
console.log(`Switching to: ${target}`);
if (current === target) {
console.log('Already on that account — nothing to do.');
return;
}
await page.getByRole('menuitem', { name: /log out/i }).click();
await page.waitForURL(/cursor\.com/, { timeout: TIMEOUT_MS });
console.log(`→ ${DASHBOARD_URL} (sign-in)`);
await page.goto(DASHBOARD_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
const googlePage = await pickGooglePageAfterOAuthClick(page);
const emailRe = new RegExp(escapeRe(target), 'i');
await googlePage.getByRole('link', { name: emailRe }).click();
await googlePage.waitForURL(/cursor\.com/, { timeout: TIMEOUT_MS });
const verifyPage =
context.pages().find((p) => p.url().includes('cursor.com')) ?? googlePage;
await assertSignedInAs(verifyPage, target);
} finally {
await dispose();
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment