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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ If you only need to authenticate an existing install, run:
bunx cursor-supermemory@latest login
```

To connect Cursor to a different Supermemory organization, run:

```bash
bunx cursor-supermemory@latest switch-org
```

Choose the organization in your browser, then restart Cursor or run
**Developer: Reload Window**. A failed or cancelled switch keeps the previously
saved browser credential.

## What it does

- **Session hooks** — injects relevant memories at session start; saves conversation highlights at session end
Expand Down Expand Up @@ -53,7 +63,7 @@ explicit project knowledge when an agent requests one scope.

| Variable | Description |
|---|---|
| `SUPERMEMORY_API_KEY` | API key (overrides all other sources) |
| `SUPERMEMORY_API_KEY` | API key (overrides browser-selected organizations and all other sources) |
| `SUPERMEMORY_API_URL` | Override the Supermemory API base URL |
| `SUPERMEMORY_REPO_TAG` | Override the unified repository container tag |
| `SUPERMEMORY_USER_TAG` | Legacy Cursor personal container to continue reading |
Expand Down
6 changes: 5 additions & 1 deletion commands/supermemory-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ bunx cursor-supermemory@latest login

This opens your browser to connect your Supermemory account to Cursor. Once connected, the AI will have persistent memory across all your coding sessions.

If the browser doesn't open automatically, visit: https://console.supermemory.ai/auth/connect?client=cursor
To choose a different organization later, run:

```bash
bunx cursor-supermemory@latest switch-org
```
16 changes: 16 additions & 0 deletions commands/supermemory-switch-org.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
name: supermemory-switch-org
description: Choose which Supermemory organization Cursor should use
---

Run the following command in the terminal:

```bash
bunx cursor-supermemory@latest switch-org
```

This opens Supermemory in your browser, where you can choose an organization.
Your existing saved credential is kept if authentication is cancelled or fails.

After a successful switch, restart Cursor or run **Developer: Reload Window**
so the MCP server uses the new organization.
138 changes: 121 additions & 17 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { randomBytes } from "node:crypto";

const CREDENTIALS_DIR = path.join(os.homedir(), ".supermemory-cursor");
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
const AUTH_PORT = 19878;
const AUTH_URL = "https://console.supermemory.ai/auth/connect";
const AUTH_URL = "https://app.supermemory.ai/auth/connect";
export const DEFAULT_API_URL = "https://api.supermemory.ai";
const SESSION_TIMEOUT_MS = 10_000;

const SUCCESS_HTML = `<!DOCTYPE html>
<html><head><style>
Expand All @@ -27,7 +29,15 @@ export function loadCredentials(): { apiKey: string; createdAt: string } | null
export function saveCredentials(apiKey: string): void {
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
const data = { apiKey, createdAt: new Date().toISOString() };
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
const temporaryFile = `${CREDENTIALS_FILE}.${process.pid}.tmp`;
try {
fs.writeFileSync(temporaryFile, JSON.stringify(data, null, 2), {
mode: 0o600,
});
fs.renameSync(temporaryFile, CREDENTIALS_FILE);
} finally {
if (fs.existsSync(temporaryFile)) fs.unlinkSync(temporaryFile);
}
}

export function clearCredentials(): boolean {
Expand All @@ -42,46 +52,140 @@ export function clearCredentials(): boolean {
}
}

export interface SessionIdentity {
organizationId?: string;
organizationName?: string;
userEmail?: string;
}

export type BrowserAuthMode = "switch_organization";

export function createBrowserAuthUrl(
callbackUrl: string,
mode?: BrowserAuthMode,
): string {
const switchMode = mode ? `&mode=${encodeURIComponent(mode)}` : "";
return `${AUTH_URL}?callback=${encodeURIComponent(callbackUrl)}&client=cursor${switchMode}`;
}

export function parseAuthCallback(url: URL, expectedState: string): string {
if (url.searchParams.get("state") !== expectedState) {
throw new Error("Invalid callback state");
}

const apiKey =
url.searchParams.get("apikey") || url.searchParams.get("api_key");
if (!apiKey?.startsWith("sm_")) throw new Error("Invalid API key");
return apiKey;
}

export async function verifyApiKey(
apiKey: string,
apiUrl = DEFAULT_API_URL,
fetchImpl: typeof fetch = fetch,
): Promise<SessionIdentity> {
const response = await fetchImpl(`${apiUrl.replace(/\/$/, "")}/v3/session`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"x-sm-source": "cursor",
},
signal: AbortSignal.timeout(SESSION_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Supermemory rejected the new credential (${response.status})`);
}

const data = (await response.json()) as {
org?: { id?: unknown; name?: unknown };
user?: { email?: unknown };
};
const identity: SessionIdentity = {
organizationId:
typeof data.org?.id === "string" ? data.org.id : undefined,
organizationName:
typeof data.org?.name === "string" ? data.org.name : undefined,
userEmail:
typeof data.user?.email === "string" ? data.user.email : undefined,
};
if (!identity.organizationId && !identity.organizationName) {
throw new Error("The new credential did not resolve to an organization");
}
return identity;
}

export async function startAuthFlow(
timeoutMs = 120_000,
): Promise<{ success: boolean; apiKey?: string; error?: string }> {
apiUrl =
process.env.SUPERMEMORY_API_URL ??
process.env.SUPERMEMORY_BASE_URL ??
DEFAULT_API_URL,
mode?: BrowserAuthMode,
): Promise<{
success: boolean;
apiKey?: string;
identity?: SessionIdentity;
error?: string;
}> {
return new Promise((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout>;
const state = randomBytes(16).toString("hex");

const server = Bun.serve({
port: AUTH_PORT,
port: 0,
hostname: "127.0.0.1",
fetch(req) {
async fetch(req) {
const url = new URL(req.url);
if (url.pathname !== "/callback") {
return new Response("Not found", { status: 404 });
}

const apiKey = url.searchParams.get("apikey") || url.searchParams.get("api_key");
if (!apiKey?.startsWith("sm_")) {
return new Response("Invalid API key", { status: 400 });
let apiKey: string;
try {
apiKey = parseAuthCallback(url, state);
} catch (error) {
const message =
error instanceof Error ? error.message : "Invalid callback";
return new Response(message, {
status: message === "Invalid callback state" ? 403 : 400,
});
}

saveCredentials(apiKey);
settled = true;
server.stop();
clearTimeout(timer);
resolve({ success: true, apiKey });
try {
const identity = await verifyApiKey(apiKey, apiUrl);
saveCredentials(apiKey);
settled = true;
server.stop();
clearTimeout(timer);
resolve({ success: true, apiKey, identity });
} catch (error) {
const message =
error instanceof Error
? error.message
: "Credential verification failed";
settled = true;
server.stop();
clearTimeout(timer);
resolve({ success: false, error: message });
return new Response(`Authentication failed: ${message}`, {
status: 400,
});
}

return new Response(SUCCESS_HTML, {
headers: { "Content-Type": "text/html" },
});
},
});

const callbackUrl = `http://localhost:${AUTH_PORT}/callback`;
const authUrl = `${AUTH_URL}?callback=${encodeURIComponent(callbackUrl)}&client=cursor`;
const callbackUrl = `http://127.0.0.1:${server.port}/callback?state=${state}`;
const authUrl = createBrowserAuthUrl(callbackUrl, mode);

process.stderr.write(`\nOpen this URL to connect Supermemory to Cursor:\n\n ${authUrl}\n\nWaiting...\n`);
const opener = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
Bun.$`${opener} ${authUrl}`.quiet().nothrow();

const timer = setTimeout(() => {
timer = setTimeout(() => {
if (!settled) {
server.stop();
resolve({ success: false, error: "Authentication timed out" });
Expand Down
93 changes: 86 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,21 @@ import {
loadCredentials,
startAuthFlow,
clearCredentials,
DEFAULT_API_URL,
verifyApiKey,
} from "./auth.ts";
import { getApiKey, getApiKeySource, loadConfig } from "./config.ts";

const command = process.argv[2];

function warnAboutCredentialOverride(organization: string): void {
const source = getApiKeySource();
if (source === "browser credentials") return;
console.warn(
`Warning: ${source} overrides the saved browser credential. Remove that API key override to use ${organization}.`,
);
}

switch (command) {
case "mcp":
await startMcpServer();
Expand All @@ -15,31 +26,98 @@ switch (command) {
case "login": {
const existing = loadCredentials();
if (existing) {
console.log("Already authenticated. Use `logout` first to re-authenticate.");
console.log(
"Already authenticated. Use `cursor-supermemory switch-org` to choose another organization.",
);
process.exit(0);
}
console.log("Opening browser to authenticate...");
const result = await startAuthFlow();
const config = loadConfig();
const result = await startAuthFlow(
120_000,
config.baseUrl ?? DEFAULT_API_URL,
);
if (result.success) {
console.log("Authenticated successfully.");
const organization =
result.identity?.organizationName ??
result.identity?.organizationId ??
"the selected organization";
console.log(
`Authenticated successfully for ${organization}.`,
);
warnAboutCredentialOverride(organization);
} else {
console.error(`Authentication failed: ${result.error}`);
process.exit(1);
}
break;
}

case "switch-org":
case "switch-organization": {
const previousCredentials = loadCredentials();
console.log("Opening Supermemory to choose an organization...");
const config = loadConfig();
const result = await startAuthFlow(
120_000,
config.baseUrl ?? DEFAULT_API_URL,
"switch_organization",
);
if (!result.success) {
console.error(`Organization switch failed: ${result.error}`);
console.error(
previousCredentials
? "Your previous browser credential is still saved."
: "No credential was saved.",
);
process.exit(1);
}

const organization =
result.identity?.organizationName ??
result.identity?.organizationId ??
"the selected organization";
console.log(`Saved a verified credential for ${organization}.`);
warnAboutCredentialOverride(organization);
console.log(
"Restart Cursor or run Developer: Reload Window before continuing so the MCP server uses the new credential.",
);
break;
}

case "logout": {
const removed = clearCredentials();
console.log(removed ? "Logged out." : "No credentials found.");
break;
}

case "status": {
const creds = loadCredentials();
if (creds) {
console.log(`Authenticated since ${creds.createdAt}`);
console.log(`API key: ${creds.apiKey.slice(0, 6)}...${creds.apiKey.slice(-4)}`);
const config = loadConfig();
const apiKey = getApiKey(config);
if (apiKey) {
const creds = loadCredentials();
if (creds?.apiKey === apiKey) {
console.log(`Authenticated since ${creds.createdAt}`);
}
console.log(
`API key: ${apiKey.slice(0, 6)}...${apiKey.slice(-4)} (${getApiKeySource()})`,
);
try {
const identity = await verifyApiKey(
apiKey,
config.baseUrl ?? DEFAULT_API_URL,
);
if (identity.organizationName || identity.organizationId) {
console.log(
`Organization: ${identity.organizationName ?? identity.organizationId}`,
);
}
if (identity.userEmail) console.log(`Account: ${identity.userEmail}`);
} catch (error) {
console.log(
`Connection: unavailable (${error instanceof Error ? error.message : "verification failed"})`,
);
}
} else {
console.log("Not authenticated. Run `cursor-supermemory login` to connect.");
}
Expand All @@ -52,6 +130,7 @@ switch (command) {
Commands:
mcp Start the MCP server (stdio)
login Authenticate with Supermemory
switch-org Choose and connect a different organization
logout Remove stored credentials
status Show authentication status`);
if (command) process.exit(1);
Expand Down
Loading