diff --git a/README.md b/README.md index a488d8f..fe634e9 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ Check the connection any time: bunx opencode-supermemory@latest status ``` +To select a different organization later: + +```bash +bunx opencode-supermemory@latest switch-organization +``` + +You can also ask the agent to run `/supermemory-switch-organization`. + **Or let your agent do it** - paste this into OpenCode: ``` @@ -46,7 +54,9 @@ bunx opencode-supermemory@latest install --no-tui This will: - Register the plugin in `~/.config/opencode/opencode.jsonc` -- Create the `/supermemory-init` command +- Create the `/supermemory-init`, `/supermemory-login`, + `/supermemory-switch-organization`, `/supermemory-logout`, and + `/supermemory-status` commands #### Step 2: Verify the config @@ -119,6 +129,24 @@ Run `/supermemory-init` to have the agent explore and memorize the codebase. +## Switching Organizations + +Run the browser organization picker again without logging out: + +```bash +bunx opencode-supermemory@latest switch-organization +``` + +Or run `/supermemory-switch-organization` inside OpenCode. The command verifies +the selected credential with `/v3/session` and prints the organization before it +replaces the stored browser credential. Cancelling, timing out, or failing +verification leaves the previous credential unchanged. + +Restart or reload OpenCode after a successful switch because the running plugin +may still hold the credential it loaded at startup. If `SUPERMEMORY_API_KEY` or +an `apiKey` in `~/.config/opencode/supermemory.jsonc`/`supermemory.json` is set, +that value takes precedence; the switch command prints a warning in that case. + ## Features ### Context Injection @@ -289,7 +317,8 @@ Create `~/.config/opencode/supermemory.jsonc`: } ``` -All fields optional. Env var `SUPERMEMORY_API_KEY` takes precedence over config file. +All fields optional. `SUPERMEMORY_API_KEY` takes precedence over the config file, +which takes precedence over credentials created by browser authentication. ### Container Tag Selection diff --git a/src/cli.ts b/src/cli.ts index bfad2e8..25b59f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,14 +4,36 @@ 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, CREDENTIALS_FILE } from "./services/auth.js"; -import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; +import { + startAuthFlow, + clearCredentials, + loadCredentials, + saveCredentials, + CREDENTIALS_FILE, +} from "./services/auth.js"; +import { + CONFIG, + CONFIG_FILE, + DEFAULT_BASE_URL, + SUPERMEMORY_API_KEY, + getApiBaseUrl, + isConfigured, + writeInstallDefaults, +} from "./config.js"; import { SupermemoryClient } from "./services/client.js"; import { getTags } from "./services/tags.js"; +import { + getCredentialOverrideWarnings, + switchOrganizationCredential, +} from "./services/organization-switch.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 SUPERMEMORY_CONFIG_FILES = [ + join(OPENCODE_CONFIG_DIR, "supermemory.jsonc"), + join(OPENCODE_CONFIG_DIR, "supermemory.json"), +]; const PLUGIN_NAME = "opencode-supermemory@latest"; const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); @@ -180,15 +202,38 @@ bunx opencode-supermemory@latest login \`\`\` This will: -1. Start a local server on port 19877 +1. Start a temporary local callback 2. Open the browser to Supermemory's authentication page -3. After the user logs in, save credentials to ~/.supermemory-opencode/credentials.json +3. Let the user select an organization +4. Save credentials to ~/.supermemory-opencode/credentials.json Wait for the command to complete, then inform the user whether authentication succeeded or failed. If the user wants to log out instead, tell them to use the /supermemory-logout command. `; +const SUPERMEMORY_SWITCH_ORGANIZATION_COMMAND = `--- +description: Switch the organization used by Supermemory +--- + +# Switch Supermemory Organization + +Run this command to let the user select which Supermemory organization OpenCode uses: + +\`\`\`bash +bunx opencode-supermemory@latest switch-organization +\`\`\` + +This opens the browser organization picker even when OpenCode is already authenticated. Existing credentials remain unchanged if the user cancels or verification fails. + +After the command completes: +1. Report the verified organization shown by the command. +2. Relay any credential override warnings. +3. Tell the user to restart or reload OpenCode because the current process may retain the credential it loaded at startup. + +Never print the full API key. +`; + const SUPERMEMORY_LOGOUT_COMMAND = `--- description: Log out from Supermemory and clear credentials --- @@ -333,6 +378,16 @@ function createCommands(): boolean { writeFileSync(loginPath, SUPERMEMORY_LOGIN_COMMAND); console.log(`✓ Created /supermemory-login command`); + const switchOrganizationPath = join( + OPENCODE_COMMAND_DIR, + "supermemory-switch-organization.md", + ); + writeFileSync( + switchOrganizationPath, + SUPERMEMORY_SWITCH_ORGANIZATION_COMMAND, + ); + console.log(`✓ Created /supermemory-switch-organization command`); + const logoutPath = join(OPENCODE_COMMAND_DIR, "supermemory-logout.md"); writeFileSync(logoutPath, SUPERMEMORY_LOGOUT_COMMAND); console.log(`✓ Created /supermemory-logout command`); @@ -434,7 +489,7 @@ async function install(options: InstallOptions): Promise { } // Step 2: Create commands - console.log("\nStep 2: Create /supermemory-init, /supermemory-login, /supermemory-logout, and /supermemory-status commands"); + console.log("\nStep 2: Create /supermemory-init, /supermemory-login, /supermemory-switch-organization, /supermemory-logout, and /supermemory-status commands"); if (options.tui) { const shouldCreate = await confirm(rl!, "Add supermemory commands?"); if (!shouldCreate) { @@ -493,16 +548,28 @@ async function install(options: InstallOptions): Promise { async function login(): Promise { const existing = loadCredentials(); if (existing) { - console.log("Already authenticated. Use 'logout' first to re-authenticate."); + console.log( + "Already authenticated. Use 'switch-organization' to choose another organization.", + ); return 0; } const result = await startAuthFlow(); if (result.success) { - console.log("\n✓ Successfully authenticated with Supermemory!"); - console.log("Restart OpenCode to activate.\n"); - return 0; + try { + saveCredentials(result.apiKey, result.apiBaseUrl); + console.log("\n✓ Successfully authenticated with Supermemory!"); + console.log("Restart OpenCode to activate.\n"); + return 0; + } catch (error) { + console.error( + `\n✗ Authentication succeeded, but credentials could not be saved: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return 1; + } } else { console.error(`\n✗ Authentication failed: ${result.error}`); return 1; @@ -515,23 +582,73 @@ function maskKey(key: string | undefined): string { 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 getConfiguredApiKeyFromFile(): + | { apiKey: string; path: string } + | undefined { + for (const path of SUPERMEMORY_CONFIG_FILES) { + try { + if (!existsSync(path)) continue; + const parsed = JSON.parse( + stripJsoncComments(readFileSync(path, "utf-8")), + ) as { apiKey?: unknown }; + if (typeof parsed.apiKey === "string" && parsed.apiKey) { + return { apiKey: parsed.apiKey, path }; + } + } catch { + continue; + } } + return undefined; } function getKeySource(): string { if (process.env.SUPERMEMORY_API_KEY) return "SUPERMEMORY_API_KEY env var"; - if (getConfiguredApiKeyFromFile()) return DEFAULT_CONFIG_FILE; + const configuredApiKey = getConfiguredApiKeyFromFile(); + if (configuredApiKey) return configuredApiKey.path; if (loadCredentials()) return CREDENTIALS_FILE; return "not configured"; } +async function switchOrganization(): Promise { + const hadExistingCredentials = loadCredentials() !== null; + console.log("Opening Supermemory so you can select an organization..."); + + const result = await switchOrganizationCredential({ + authorize: () => + startAuthFlow(undefined, { mode: "switch_organization" }), + save: saveCredentials, + defaultApiBaseUrl: DEFAULT_BASE_URL, + }); + + if (!result.success) { + console.error(`\n✗ Organization switch failed: ${result.error}`); + if (hadExistingCredentials) { + console.log("Your existing browser credentials were kept unchanged."); + } + return 1; + } + + const organization = result.organization.name + ? `${result.organization.name} (${result.organization.id})` + : result.organization.id; + console.log(`\n✓ Supermemory organization switched to ${organization}.`); + console.log(`Verified with ${result.apiBaseUrl}/v3/session.`); + + const configuredApiKey = getConfiguredApiKeyFromFile(); + const warnings = getCredentialOverrideWarnings({ + environmentApiKey: Boolean(process.env.SUPERMEMORY_API_KEY), + configApiKeyPath: configuredApiKey?.path, + }); + for (const warning of warnings) { + console.warn(`⚠ ${warning}`); + } + + console.log( + "Restart or reload OpenCode before continuing; the current process may still be using the credential it loaded at startup.\n", + ); + return 0; +} + function getDevTlsHint(apiUrl: string): string | null { if (!apiUrl.includes(".dev.supermemory.ai")) return null; if (process.env.NODE_EXTRA_CA_CERTS) return null; @@ -656,12 +773,15 @@ Commands: --no-tui Non-interactive mode (for LLM agents) --disable-context-recovery Disable Oh My OpenCode's context hook login Authenticate with Supermemory (opens browser) + switch-organization + Select and verify the organization used by Supermemory logout Clear stored credentials status Show Supermemory connection status Examples: bunx opencode-supermemory@latest install bunx opencode-supermemory@latest login + bunx opencode-supermemory@latest switch-organization bunx opencode-supermemory@latest logout bunx opencode-supermemory@latest status `); @@ -685,6 +805,8 @@ if (args[0] === "install") { install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); } else if (args[0] === "login") { login().then((code) => process.exit(code)); +} else if (args[0] === "switch-organization" || args[0] === "switch-org") { + switchOrganization().then((code) => process.exit(code)); } else if (args[0] === "logout") { process.exit(logout()); } else if (args[0] === "status") { diff --git a/src/services/auth.ts b/src/services/auth.ts index 5e368c0..8f414ea 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,14 +1,22 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { arch, homedir, hostname, platform } from "node:os"; import { randomBytes } from "node:crypto"; import type { AddressInfo } from "node:net"; import { openUrl } from "./openUrl.js"; +import { PLUGIN_VERSION } from "../version.js"; const CREDENTIALS_DIR = join(homedir(), ".supermemory-opencode"); 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_BASE_URL = process.env.SUPERMEMORY_AUTH_URL || "https://app.supermemory.ai/auth/connect"; const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 5 * 60_000; const CLIENT_NAME = "opencode"; @@ -28,7 +36,7 @@ export function loadCredentials(): Credentials | null { } } -function normalizeApiBaseUrl(apiBaseUrl: string | null | undefined): string | undefined { +export function normalizeApiBaseUrl(apiBaseUrl: string | null | undefined): string | undefined { if (!apiBaseUrl) return undefined; try { const url = new URL(apiBaseUrl); @@ -50,7 +58,19 @@ export function saveCredentials(apiKey: string, apiBaseUrl?: string): void { }; const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl); if (normalizedApiBaseUrl) credentials.apiBaseUrl = normalizedApiBaseUrl; - writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 }); + + const temporaryFile = join( + CREDENTIALS_DIR, + `.credentials-${process.pid}-${randomBytes(6).toString("hex")}.tmp`, + ); + try { + writeFileSync(temporaryFile, JSON.stringify(credentials, null, 2), { + mode: 0o600, + }); + renameSync(temporaryFile, CREDENTIALS_FILE); + } finally { + if (existsSync(temporaryFile)) rmSync(temporaryFile); + } } export function clearCredentials(): boolean { @@ -59,13 +79,34 @@ export function clearCredentials(): boolean { return true; } -export interface AuthResult { - success: boolean; - apiKey?: string; - error?: string; +export type AuthResult = + | { success: true; apiKey: string; apiBaseUrl?: string } + | { success: false; error: string }; + +export interface AuthFlowOptions { + mode?: "switch_organization"; } -export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { +export function buildAuthUrl( + callbackUrl: string, + options: AuthFlowOptions = {}, +): string { + const params = new URLSearchParams({ + callback: callbackUrl, + client: CLIENT_NAME, + hostname: `opencode - ${hostname()}`, + os: `${platform()}-${arch()}`, + cwd: process.cwd(), + cli_version: PLUGIN_VERSION, + }); + if (options.mode) params.set("mode", options.mode); + return `${AUTH_BASE_URL}?${params.toString()}`; +} + +export function startAuthFlow( + timeoutMs = AUTH_TIMEOUT, + options: AuthFlowOptions = {}, +): Promise { return new Promise((resolve) => { let resolved = false; const stateToken = randomBytes(16).toString("hex"); @@ -91,10 +132,30 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { `); + return; + } + + const authError = + url.searchParams.get("error_description") || + url.searchParams.get("error"); + if (authError) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(` + + + Cancelled + +
+

Connection Cancelled

+

Your existing credentials were not changed.

+
+ + + `); resolved = true; clearTimeout(timer); server.close(); - resolve({ success: false, error: "Invalid auth state" }); + resolve({ success: false, error: authError }); return; } @@ -102,7 +163,6 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { 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(` @@ -110,8 +170,8 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { Success
-

Connected!

-

You can close this window and return to your terminal.

+

Authorization Received!

+

Return to your terminal to finish verification.

@@ -119,7 +179,11 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { resolved = true; clearTimeout(timer); server.close(); - resolve({ success: true, apiKey }); + resolve({ + success: true, + apiKey, + apiBaseUrl: normalizeApiBaseUrl(apiBaseUrl), + }); } else { res.writeHead(400, { "Content-Type": "text/html" }); res.end(` @@ -156,15 +220,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { 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.10", - }); - const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; + const authUrl = buildAuthUrl(callbackUrl, options); console.log("Opening browser for authentication..."); console.log(`If it doesn't open, visit: ${authUrl}`); diff --git a/src/services/organization-switch.ts b/src/services/organization-switch.ts new file mode 100644 index 0000000..b6c34bb --- /dev/null +++ b/src/services/organization-switch.ts @@ -0,0 +1,133 @@ +import type { AuthResult } from "./auth.js"; + +const SESSION_TIMEOUT_MS = 30_000; + +export interface SessionOrganization { + id: string; + name?: string; +} + +export type OrganizationVerificationResult = + | { success: true; organization: SessionOrganization } + | { success: false; error: string }; + +export type OrganizationSwitchResult = + | { + success: true; + organization: SessionOrganization; + apiBaseUrl: string; + } + | { success: false; error: string }; + +interface OrganizationSwitchDependencies { + authorize: () => Promise; + save: (apiKey: string, apiBaseUrl?: string) => void; + defaultApiBaseUrl: string; + verify?: ( + apiKey: string, + apiBaseUrl: string, + ) => Promise; +} + +interface SessionResponse { + org?: { + id?: unknown; + name?: unknown; + }; +} + +export async function verifyOrganizationCredential( + apiKey: string, + apiBaseUrl: string, + fetchImpl: typeof fetch = fetch, +): Promise { + try { + const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/v3/session`, { + headers: { + Authorization: `Bearer ${apiKey}`, + "x-sm-source": "opencode", + }, + signal: AbortSignal.timeout(SESSION_TIMEOUT_MS), + }); + + if (!response.ok) { + return { + success: false, + error: `The selected organization could not be verified (HTTP ${response.status}).`, + }; + } + + const session = (await response.json()) as SessionResponse; + if (!session.org || typeof session.org.id !== "string" || !session.org.id) { + return { + success: false, + error: "The selected organization was missing from the session response.", + }; + } + + return { + success: true, + organization: { + id: session.org.id, + name: + typeof session.org.name === "string" && session.org.name + ? session.org.name + : undefined, + }, + }; + } catch (error) { + return { + success: false, + error: `The selected organization could not be verified: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } +} + +export async function switchOrganizationCredential( + dependencies: OrganizationSwitchDependencies, +): Promise { + const authorization = await dependencies.authorize(); + if (!authorization.success) return authorization; + + const apiBaseUrl = authorization.apiBaseUrl ?? dependencies.defaultApiBaseUrl; + const verify = dependencies.verify ?? verifyOrganizationCredential; + const verification = await verify(authorization.apiKey, apiBaseUrl); + if (!verification.success) return verification; + + try { + dependencies.save(authorization.apiKey, authorization.apiBaseUrl); + } catch (error) { + return { + success: false, + error: `The verified credentials could not be saved: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + return { + success: true, + organization: verification.organization, + apiBaseUrl, + }; +} + +export function getCredentialOverrideWarnings(options: { + environmentApiKey: boolean; + configApiKeyPath?: string; +}): string[] { + const warnings: string[] = []; + if (options.environmentApiKey) { + warnings.push( + "SUPERMEMORY_API_KEY is set and takes precedence over the browser credential.", + ); + } + if (options.configApiKeyPath) { + warnings.push( + `apiKey in ${options.configApiKeyPath} takes precedence over the browser credential.`, + ); + } + return warnings; +}