diff --git a/README.md b/README.md index 7c626d2..01e0b3f 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,16 @@ Your agent remembers what you tell it - across sessions, across projects. bunx opencode-supermemory@latest install ``` -Then get your API key from [app.supermemory.ai](https://app.supermemory.ai/?view=integrations) and set it: +Then authenticate with Supermemory: ```bash -export SUPERMEMORY_API_KEY="sm_..." +bunx opencode-supermemory@latest login +``` + +Check the connection any time: + +```bash +bunx opencode-supermemory@latest status ``` **Or let your agent do it** - paste this into OpenCode: @@ -77,17 +83,15 @@ If not, add it manually: } ``` -#### Step 3: Configure API key - -Ask the user to get their API key from [app.supermemory.ai](https://app.supermemory.ai/?view=integrations). +#### Step 3: Authenticate -Then set it via environment variable: +Run the browser authentication flow: ```bash -export SUPERMEMORY_API_KEY="sm_..." +bunx opencode-supermemory@latest login ``` -Or create `~/.config/opencode/supermemory.jsonc`: +For headless environments, users can still set `SUPERMEMORY_API_KEY` manually or create `~/.config/opencode/supermemory.jsonc`: ```jsonc { @@ -100,12 +104,12 @@ Or create `~/.config/opencode/supermemory.jsonc`: Tell the user to restart OpenCode and run: ```bash -opencode -c +bunx opencode-supermemory@latest status ``` -They should see `supermemory` in the tools list. If not, check: +If it is not connected, check: -1. Is `SUPERMEMORY_API_KEY` set? +1. Is the user authenticated, or is `SUPERMEMORY_API_KEY` set? 2. Is the plugin in `opencode.jsonc`? 3. Check logs: `tail ~/.opencode-supermemory.log` diff --git a/src/cli.ts b/src/cli.ts index 9114fae..3073493 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,13 +4,16 @@ import { join } from "node:path"; import { homedir } from "node:os"; import * as readline from "node:readline"; import { stripJsoncComments } from "./services/jsonc.js"; -import { startAuthFlow, clearCredentials, loadCredentials } from "./services/auth.js"; -import { writeInstallDefaults, CONFIG_FILE } from "./config.js"; +import { startAuthFlow, clearCredentials, loadCredentials, CREDENTIALS_FILE } from "./services/auth.js"; +import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; +import { SupermemoryClient } from "./services/client.js"; +import { getTags } from "./services/tags.js"; const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); const OPENCODE_COMMAND_DIR = join(OPENCODE_CONFIG_DIR, "command"); const OH_MY_OPENCODE_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json"); const PLUGIN_NAME = "opencode-supermemory@latest"; +const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); const SUPERMEMORY_INIT_COMMAND = `--- description: Initialize Supermemory with comprehensive codebase knowledge @@ -203,6 +206,23 @@ This will remove the saved credentials from ~/.supermemory-opencode/credentials. Inform the user whether logout succeeded and that they'll need to run /supermemory-login to re-authenticate. `; +const SUPERMEMORY_STATUS_COMMAND = `--- +description: Show Supermemory connection status +--- + +# Supermemory Status + +Run this command to check whether OpenCode is connected to Supermemory: + +\`\`\`bash +bunx opencode-supermemory@latest status +\`\`\` + +Report the connection status, credential source, API URL, and account information if available. + +Never print the full API key. +`; + function createReadline(): readline.Interface { return readline.createInterface({ input: process.stdin, @@ -317,6 +337,10 @@ function createCommands(): boolean { writeFileSync(logoutPath, SUPERMEMORY_LOGOUT_COMMAND); console.log(`āœ“ Created /supermemory-logout command`); + const statusPath = join(OPENCODE_COMMAND_DIR, "supermemory-status.md"); + writeFileSync(statusPath, SUPERMEMORY_STATUS_COMMAND); + console.log(`āœ“ Created /supermemory-status command`); + return true; } @@ -377,7 +401,7 @@ interface InstallOptions { async function install(options: InstallOptions): Promise { console.log("\n🧠 opencode-supermemory installer\n"); - writeInstallDefaults(existsSync(CONFIG_FILE)); + writeInstallDefaults(existsSync(DEFAULT_CONFIG_FILE)); const rl = options.tui ? createReadline() : null; @@ -410,7 +434,7 @@ async function install(options: InstallOptions): Promise { } // Step 2: Create commands - console.log("\nStep 2: Create /supermemory-init, /supermemory-login, and /supermemory-logout commands"); + console.log("\nStep 2: Create /supermemory-init, /supermemory-login, /supermemory-logout, and /supermemory-status commands"); if (options.tui) { const shouldCreate = await confirm(rl!, "Add supermemory commands?"); if (!shouldCreate) { @@ -485,12 +509,152 @@ async function login(): Promise { } } +function maskKey(key: string | undefined): string { + if (!key) return "not set"; + if (key.length <= 12) return `${key.slice(0, 4)}...`; + return `${key.slice(0, 6)}...${key.slice(-4)}`; +} + +function getConfiguredApiKeyFromFile(): string | undefined { + try { + if (!existsSync(DEFAULT_CONFIG_FILE)) return undefined; + const parsed = JSON.parse(readFileSync(DEFAULT_CONFIG_FILE, "utf-8")) as { apiKey?: string }; + return parsed.apiKey; + } catch { + return undefined; + } +} + +function getKeySource(): string { + if (process.env.SUPERMEMORY_API_KEY) return "SUPERMEMORY_API_KEY env var"; + if (getConfiguredApiKeyFromFile()) return DEFAULT_CONFIG_FILE; + if (loadCredentials()) return CREDENTIALS_FILE; + return "not configured"; +} + +function getDevTlsHint(apiUrl: string): string | null { + if (!apiUrl.includes(".dev.supermemory.ai")) return null; + if (process.env.NODE_EXTRA_CA_CERTS) return null; + return "Dev API TLS: set NODE_EXTRA_CA_CERTS to your Portless CA before starting OpenCode."; +} + +async function fetchJson(apiUrl: string, path: string): Promise { + if (!SUPERMEMORY_API_KEY) return null; + try { + const response = await fetch(`${apiUrl}${path}`, { + headers: { + Authorization: `Bearer ${SUPERMEMORY_API_KEY}`, + "x-sm-source": "opencode", + }, + }); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + +function findAccountInfo(value: unknown): { email?: string; name?: string; userId?: string; orgName?: string } { + const seen = new Set(); + const stack = [value]; + const result: { email?: string; name?: string; userId?: string; orgName?: string } = {}; + + while (stack.length > 0) { + const item = stack.pop(); + if (!item || typeof item !== "object" || seen.has(item)) continue; + seen.add(item); + + const record = item as Record; + for (const [key, raw] of Object.entries(record)) { + const lower = key.toLowerCase(); + if (!result.email && lower === "email" && typeof raw === "string") result.email = raw; + if (!result.name && lower === "name" && typeof raw === "string") result.name = raw; + if (!result.userId && (lower === "userid" || lower === "user_id") && typeof raw === "string") result.userId = raw; + if (!result.orgName && (lower === "organizationname" || lower === "orgname") && typeof raw === "string") result.orgName = raw; + + if (raw && typeof raw === "object") stack.push(raw); + } + } + + return result; +} + +async function getAccountInfo(apiUrl: string): Promise<{ email?: string; name?: string; userId?: string; orgName?: string }> { + for (const path of ["/v3/auth/account/memberships", "/v3/account/memberships", "/v3/me"]) { + const data = await fetchJson(apiUrl, path); + const info = findAccountInfo(data); + if (info.email || info.name || info.userId || info.orgName) return info; + } + return {}; +} + +async function status(): Promise { + const apiUrl = getApiBaseUrl(); + const tags = getTags(process.cwd()); + const lines: string[] = []; + + lines.push("supermemory status"); + lines.push(""); + lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); + lines.push(`API key: ${maskKey(SUPERMEMORY_API_KEY)} (${getKeySource()})`); + lines.push(`API URL: ${apiUrl}`); + lines.push("Memory scope: current project + user profile"); + lines.push(`Recall mode: ${CONFIG.autoRecallEveryPrompt ? "auto-recall on every prompt" : "session/event based"}`); + lines.push(`Capture cadence: ${CONFIG.captureEveryNTurns > 0 ? `every ${CONFIG.captureEveryNTurns} turn${CONFIG.captureEveryNTurns === 1 ? "" : "s"} + session end` : "session end only"}`); + lines.push(`Project tag: ${tags.project}`); + lines.push(`User tag: ${tags.user}`); + + if (!isConfigured()) { + lines.push(""); + lines.push("Run /supermemory-login to connect, or set SUPERMEMORY_API_KEY."); + console.log(lines.join("\n")); + return 0; + } + + const client = new SupermemoryClient(); + const [profileResult, accountInfo] = await Promise.all([ + client.getProfile(tags.user), + getAccountInfo(apiUrl), + ]); + + lines[2] = profileResult.success ? "Connected: yes" : "Connected: no"; + + if (accountInfo.email || accountInfo.name || accountInfo.userId || accountInfo.orgName) { + lines.push(""); + lines.push("Account:"); + if (accountInfo.email) lines.push(`Email: ${accountInfo.email}`); + if (accountInfo.name) lines.push(`Name: ${accountInfo.name}`); + if (accountInfo.userId) lines.push(`User ID: ${accountInfo.userId}`); + if (accountInfo.orgName) lines.push(`Organization: ${accountInfo.orgName}`); + } else { + lines.push(""); + lines.push("Account: authenticated API key (account details unavailable from API key)"); + } + + if (!profileResult.success) { + lines.push(""); + lines.push(`Connection check failed: ${profileResult.error}`); + const devTlsHint = getDevTlsHint(apiUrl); + if (devTlsHint) lines.push(devTlsHint); + } + + console.log(lines.join("\n")); + return 0; +} + function logout(): number { if (clearCredentials()) { console.log("āœ“ Logged out. Credentials cleared."); + console.log("This only logs out this local OpenCode install. To revoke the account-level OpenCode integration key, disconnect it from the Supermemory integrations page."); + if (process.env.SUPERMEMORY_API_KEY) { + console.log("SUPERMEMORY_API_KEY is still set in this shell, so memory may remain active until you unset it or restart OpenCode."); + } return 0; } else { console.log("No credentials found."); + if (process.env.SUPERMEMORY_API_KEY) { + console.log("SUPERMEMORY_API_KEY is still set in this shell."); + } return 0; } } @@ -505,11 +669,13 @@ Commands: --disable-context-recovery Disable Oh My OpenCode's context hook login Authenticate with Supermemory (opens browser) logout Clear stored credentials + status Show Supermemory connection status Examples: bunx opencode-supermemory@latest install bunx opencode-supermemory@latest login bunx opencode-supermemory@latest logout + bunx opencode-supermemory@latest status `); } @@ -533,6 +699,8 @@ if (args[0] === "install") { login().then((code) => process.exit(code)); } else if (args[0] === "logout") { process.exit(logout()); +} else if (args[0] === "status") { + status().then((code) => process.exit(code)); } else { console.error(`Unknown command: ${args[0]}`); printHelp(); diff --git a/src/config.ts b/src/config.ts index de238f9..8aa01a6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -101,7 +101,16 @@ function getApiKey(): string | undefined { } export const SUPERMEMORY_API_KEY = getApiKey(); +export function getApiBaseUrl(): string { + return ( + process.env.SUPERMEMORY_API_URL || + process.env.SUPERMEMORY_BASE_URL || + loadCredentials()?.apiBaseUrl || + "https://api.supermemory.ai" + ); +} export const CONFIG_FILE = CONFIG_FILES[1]; +const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(CONFIG_DIR, "supermemory.json"); export const CONFIG = { similarityThreshold: fileConfig.similarityThreshold ?? DEFAULTS.similarityThreshold, @@ -140,5 +149,5 @@ export function writeInstallDefaults(isExistingInstall: boolean): void { next.autoRecallEveryPrompt = false; next.captureEveryNTurns = 0; } - writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2)); + writeFileSync(DEFAULT_CONFIG_FILE, JSON.stringify(next, null, 2)); } diff --git a/src/services/auth.ts b/src/services/auth.ts index d642a72..8d1f5c2 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,17 +1,20 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { exec } from "node:child_process"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { arch, homedir, hostname, platform } from "node:os"; +import { randomBytes } from "node:crypto"; +import type { AddressInfo } from "node:net"; +import { openUrl } from "./openUrl.js"; const CREDENTIALS_DIR = join(homedir(), ".supermemory-opencode"); -const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json"); -const AUTH_PORT = 19877; -const AUTH_BASE_URL = process.env.SUPERMEMORY_AUTH_URL || "https://app.supermemory.ai/auth/connect"; +export const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json"); +const AUTH_BASE_URL = process.env.SUPERMEMORY_AUTH_URL || "https://app.supermemory.ai/auth/agent-connect"; +const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 5 * 60_000; const CLIENT_NAME = "opencode"; -interface Credentials { +export interface Credentials { apiKey: string; + apiBaseUrl?: string; createdAt: string; } @@ -25,12 +28,28 @@ export function loadCredentials(): Credentials | null { } } -export function saveCredentials(apiKey: string): void { +function normalizeApiBaseUrl(apiBaseUrl: string | null | undefined): string | undefined { + if (!apiBaseUrl) return undefined; + try { + const url = new URL(apiBaseUrl); + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + url.pathname = url.pathname.replace(/\/+$/, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return undefined; + } +} + +export function saveCredentials(apiKey: string, apiBaseUrl?: string): void { mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); const credentials: Credentials = { apiKey, createdAt: new Date().toISOString(), }; + const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl); + if (normalizedApiBaseUrl) credentials.apiBaseUrl = normalizedApiBaseUrl; writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 }); } @@ -40,41 +59,50 @@ export function clearCredentials(): boolean { return true; } -function openBrowser(url: string): void { - const platform = process.platform; - - const commands: Record = { - darwin: `open "${url}"`, - win32: `start "" "${url}"`, - linux: `xdg-open "${url}"`, - }; - - const cmd = commands[platform] ?? `xdg-open "${url}"`; - exec(cmd, (err) => { - if (err) console.error("Failed to open browser:", err.message); - }); -} - export interface AuthResult { success: boolean; apiKey?: string; error?: string; } -export function startAuthFlow(timeoutMs = 120000): Promise { +export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { return new Promise((resolve) => { let resolved = false; + const stateToken = randomBytes(16).toString("hex"); const server = createServer((req: IncomingMessage, res: ServerResponse) => { if (resolved) return; - const url = new URL(req.url || "/", `http://localhost:${AUTH_PORT}`); + const url = new URL(req.url || "/", "http://127.0.0.1"); if (url.pathname === "/callback") { - const apiKey = url.searchParams.get("apikey"); + const callbackState = url.searchParams.get("state"); + if (callbackState !== stateToken) { + res.writeHead(403, { "Content-Type": "text/html" }); + res.end(` + + + Error + +
+

Connection Failed

+

Invalid auth state. Please try again.

+
+ + + `); + resolved = true; + clearTimeout(timer); + server.close(); + resolve({ success: false, error: "Invalid auth state" }); + return; + } - if (apiKey) { - saveCredentials(apiKey); + const apiKey = url.searchParams.get("apikey") || url.searchParams.get("api_key"); + const apiBaseUrl = url.searchParams.get("api_url") || url.searchParams.get("api_base_url"); + + if (apiKey?.startsWith("sm_")) { + saveCredentials(apiKey, apiBaseUrl ?? undefined); res.writeHead(200, { "Content-Type": "text/html" }); res.end(` @@ -82,13 +110,14 @@ export function startAuthFlow(timeoutMs = 120000): Promise { Success
-

āœ“ Connected!

+

Connected!

You can close this window and return to your terminal.

`); resolved = true; + clearTimeout(timer); server.close(); resolve({ success: true, apiKey }); } else { @@ -99,13 +128,14 @@ export function startAuthFlow(timeoutMs = 120000): Promise { Error
-

āœ— Connection Failed

+

Connection Failed

No API key received. Please try again.

`); resolved = true; + clearTimeout(timer); server.close(); resolve({ success: false, error: "No API key received" }); } @@ -116,23 +146,39 @@ export function startAuthFlow(timeoutMs = 120000): Promise { }); server.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - resolve({ success: false, error: `Port ${AUTH_PORT} is already in use` }); - } else { + if (!resolved) { + resolved = true; + clearTimeout(timer); resolve({ success: false, error: err.message }); } }); - server.listen(AUTH_PORT, () => { - const callbackUrl = `http://localhost:${AUTH_PORT}/callback`; - const authUrl = `${AUTH_BASE_URL}?callback=${encodeURIComponent(callbackUrl)}&client=${CLIENT_NAME}`; + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + const callbackUrl = `http://127.0.0.1:${port}/callback?state=${stateToken}`; + const params = new URLSearchParams({ + callback: callbackUrl, + client: CLIENT_NAME, + hostname: `opencode - ${hostname()}`, + os: `${platform()}-${arch()}`, + cwd: process.cwd(), + cli_version: "2.0.6", + }); + const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; console.log("Opening browser for authentication..."); console.log(`If it doesn't open, visit: ${authUrl}`); - openBrowser(authUrl); + openUrl(authUrl).catch((error) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + server.close(); + resolve({ success: false, error: `Failed to open browser: ${error.message}` }); + } + }); }); - setTimeout(() => { + const timer = setTimeout(() => { if (!resolved) { resolved = true; server.close(); diff --git a/src/services/client.ts b/src/services/client.ts index 67c2cb2..ad4d848 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -1,5 +1,5 @@ import Supermemory from "supermemory"; -import { CONFIG, SUPERMEMORY_API_KEY, isConfigured } from "../config.js"; +import { CONFIG, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured } from "../config.js"; import { log } from "./logger.js"; import type { ConversationIngestResponse, @@ -56,6 +56,7 @@ export class SupermemoryClient { // writes to the OpenCode plugin in PostHog / `document.source`. this.client = new Supermemory({ apiKey: SUPERMEMORY_API_KEY, + baseURL: getApiBaseUrl(), defaultHeaders: { "x-sm-source": "opencode" }, }); this.client.settings.update({ @@ -121,7 +122,7 @@ export class SupermemoryClient { sm_source: "opencode", sm_capture_mode: metadata?.sm_capture_mode ?? "tool", ...(metadata ?? {}), - } as Record; + } as unknown as Record; const result = await withTimeout( this.getClient().memories.add({ diff --git a/src/services/openUrl.ts b/src/services/openUrl.ts new file mode 100644 index 0000000..dae8053 --- /dev/null +++ b/src/services/openUrl.ts @@ -0,0 +1,34 @@ +import { execFile } from "node:child_process"; + +function run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { windowsHide: true }, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +export async function openUrl(url: string | URL): Promise { + const href = url.toString(); + if (!/^https?:\/\//i.test(href)) { + throw new Error("Refusing to open non-http URL"); + } + + if (process.platform === "win32") { + try { + await run("rundll32.exe", ["url.dll,FileProtocolHandler", href]); + return; + } catch {} + + await run("cmd.exe", ["/c", "start", '""', href]); + return; + } + + if (process.platform === "darwin") { + await run("open", [href]); + return; + } + + await run("xdg-open", [href]); +}