add Claude SDK gateway installer setup

This commit is contained in:
Alex Newman
2026-05-04 20:21:36 -07:00
parent 8bef7c6a34
commit c4097b4ebb
8 changed files with 386 additions and 202 deletions
+157 -12
View File
@@ -7,6 +7,7 @@ import { homedir } from 'os';
import { dirname, join } from 'path';
import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { loadClaudeMemEnv, saveClaudeMemEnv } from '../../shared/EnvManager.js';
import { ensureWorkerStarted, type WorkerStartResult } from '../../services/worker-spawner.js';
import {
ensureBun,
@@ -584,13 +585,103 @@ function mergeSettings(updates: Record<string, string>): boolean {
}
type ProviderId = 'claude' | 'gemini' | 'openrouter';
type ClaudeAccessMode = 'subscription' | 'api-key';
type ClaudeApiMode = 'direct' | 'gateway';
async function promptProvider(options: InstallOptions): Promise<ProviderId> {
const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude';
const persistClaudeProvider = () => {
const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: 'claude' });
if (wrote) log.info('Saved provider=claude to ~/.claude-mem/settings.json');
const persistClaudeProvider = (authMethod: 'subscription' | 'api-key' | 'gateway' = 'subscription') => {
const wrote = mergeSettings({
CLAUDE_MEM_PROVIDER: 'claude',
CLAUDE_MEM_CLAUDE_AUTH_METHOD: authMethod,
});
if (wrote) log.info('Saved Claude Agent SDK configuration to ~/.claude-mem/settings.json');
};
const useSubscriptionAuth = () => {
persistClaudeProvider('subscription');
saveClaudeMemEnv({
ANTHROPIC_API_KEY: '',
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
log.info('Configured claude-mem to use your logged-in Claude SDK account.');
};
const configureDirectApiKey = async (): Promise<void> => {
const existing = loadClaudeMemEnv().ANTHROPIC_API_KEY || '';
if (existing.trim().length > 0) {
saveClaudeMemEnv({
ANTHROPIC_API_KEY: existing.trim(),
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
persistClaudeProvider('api-key');
return;
}
const apiKeyResult = await p.password({
message: 'Paste your Anthropic API key:',
mask: '*',
validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
});
if (p.isCancel(apiKeyResult)) {
log.warn('API key prompt cancelled — using your logged-in Claude SDK account.');
useSubscriptionAuth();
return;
}
saveClaudeMemEnv({
ANTHROPIC_API_KEY: String(apiKeyResult).trim(),
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
persistClaudeProvider('api-key');
log.info('Saved Anthropic API key for the Claude Agent SDK path.');
};
const configureGateway = async (): Promise<void> => {
const existing = loadClaudeMemEnv();
const baseUrlResult = await p.text({
message: 'Gateway URL:',
placeholder: existing.ANTHROPIC_BASE_URL || 'http://localhost:4000',
defaultValue: existing.ANTHROPIC_BASE_URL || '',
validate: (v?: string) => {
const value = v?.trim() ?? '';
if (!value) return 'Gateway URL required';
try {
new URL(value);
return undefined;
} catch {
return 'Enter a valid URL, for example http://localhost:4000';
}
},
});
if (p.isCancel(baseUrlResult)) {
log.warn('Gateway setup cancelled — using your logged-in Claude SDK account.');
useSubscriptionAuth();
return;
}
const tokenResult = await p.password({
message: 'Gateway key/token (leave blank if your local gateway does not require one):',
mask: '*',
});
if (p.isCancel(tokenResult)) {
log.warn('Gateway key prompt cancelled — continuing without a gateway token.');
}
saveClaudeMemEnv({
ANTHROPIC_API_KEY: '',
ANTHROPIC_BASE_URL: String(baseUrlResult).trim(),
ANTHROPIC_AUTH_TOKEN: p.isCancel(tokenResult) ? '' : String(tokenResult).trim(),
});
persistClaudeProvider('gateway');
log.info('Configured Claude Agent SDK gateway in ~/.claude-mem/.env.');
};
if (!isInteractive) {
@@ -611,21 +702,44 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
if (options.provider) {
selectedProvider = options.provider;
} else {
const result = await p.select<ProviderId>({
message: 'Which LLM provider should claude-mem use to compress observations?',
const result = await p.select<ClaudeAccessMode>({
message: 'Do you use a subscription plan or an API key/gateway for the memory agent?',
options: [
{ value: 'claude', label: 'Claude Code auth (default — no extra setup, uses your existing Claude Code subscription)' },
{ value: 'gemini', label: 'Gemini API key (free tier available — fast and cheap)' },
{ value: 'openrouter', label: 'OpenRouter API key (BYO model — wide selection of frontier and open models)' },
{ value: 'subscription', label: 'Subscription plan (recommended — uses your logged-in Claude SDK account)' },
{ value: 'api-key', label: 'API key or gateway (Anthropic, LiteLLM, or compatible proxy)' },
],
initialValue: initialProvider,
initialValue: initialProvider === 'claude' ? 'subscription' : 'api-key',
});
if (p.isCancel(result)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
selectedProvider = result as ProviderId;
if (result === 'subscription') {
useSubscriptionAuth();
return 'claude';
}
const apiModeResult = await p.select<ClaudeApiMode>({
message: 'How should claude-mem connect?',
options: [
{ value: 'direct', label: 'Anthropic API key' },
{ value: 'gateway', label: 'LiteLLM or custom gateway' },
],
initialValue: loadClaudeMemEnv().ANTHROPIC_BASE_URL ? 'gateway' : 'direct',
});
if (p.isCancel(apiModeResult)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
if (apiModeResult === 'gateway') {
await configureGateway();
} else {
await configureDirectApiKey();
}
return 'claude';
}
if (selectedProvider === 'claude') {
@@ -648,7 +762,7 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
const apiKeyResult = await p.password({
message: `Paste your ${providerLabel} API key:`,
mask: '*',
validate: (v: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
});
if (p.isCancel(apiKeyResult)) {
@@ -674,8 +788,10 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
'claude-sonnet-4-6',
'claude-opus-4-7',
]);
const authMethod = getSetting('CLAUDE_MEM_CLAUDE_AUTH_METHOD');
const allowCustomModel = authMethod === 'gateway';
if (options.model) {
if (options.model && !allowCustomModel) {
if (!allowed.has(options.model)) {
throw new Error(
`Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`,
@@ -687,10 +803,39 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
}
return;
}
if (options.model && allowCustomModel) {
const wrote = mergeSettings({ CLAUDE_MEM_MODEL: options.model });
if (wrote) {
log.info(`Saved gateway model=${options.model} to ~/.claude-mem/settings.json`);
}
return;
}
if (!isInteractive) return;
const initialModel = getSetting('CLAUDE_MEM_MODEL');
if (allowCustomModel) {
const result = await p.text({
message: 'Which model should the gateway use?',
placeholder: 'claude-haiku-4-5-20251001',
defaultValue: initialModel || 'claude-haiku-4-5-20251001',
validate: (v?: string) => (!v || v.trim().length === 0) ? 'Model required' : undefined,
});
if (p.isCancel(result)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
const selectedModel = String(result).trim();
const wrote = mergeSettings({ CLAUDE_MEM_MODEL: selectedModel });
if (wrote) {
log.info(`Saved gateway model=${selectedModel} to ~/.claude-mem/settings.json`);
}
return;
}
const initialValue = allowed.has(initialModel) ? initialModel : 'claude-haiku-4-5-20251001';
const result = await p.select<string>({
@@ -87,6 +87,7 @@ export class SettingsRoutes extends BaseRouteHandler {
'CLAUDE_MEM_WORKER_PORT',
'CLAUDE_MEM_WORKER_HOST',
'CLAUDE_MEM_PROVIDER',
'CLAUDE_MEM_CLAUDE_AUTH_METHOD',
'CLAUDE_MEM_GEMINI_API_KEY',
'CLAUDE_MEM_GEMINI_MODEL',
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
@@ -194,6 +195,13 @@ export class SettingsRoutes extends BaseRouteHandler {
}
}
if (settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD) {
const validClaudeAuthMethods = ['subscription', 'api-key', 'gateway', 'cli'];
if (!validClaudeAuthMethods.includes(settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD)) {
return { valid: false, error: 'CLAUDE_MEM_CLAUDE_AUTH_METHOD must be "subscription", "api-key", "gateway", or "cli"' };
}
}
if (settings.CLAUDE_MEM_GEMINI_MODEL) {
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview'];
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
+24 -5
View File
@@ -22,6 +22,7 @@ const BLOCKED_ENV_VARS = [
export interface ClaudeMemEnv {
ANTHROPIC_API_KEY?: string;
ANTHROPIC_BASE_URL?: string;
ANTHROPIC_AUTH_TOKEN?: string;
GEMINI_API_KEY?: string;
OPENROUTER_API_KEY?: string;
}
@@ -56,7 +57,7 @@ function parseEnvFile(content: string): Record<string, string> {
function serializeEnvFile(env: Record<string, string>): string {
const lines: string[] = [
'# claude-mem credentials',
'# This file stores API keys for claude-mem memory agent',
'# This file stores keys and gateway settings for the claude-mem memory agent',
'# Edit this file or use claude-mem settings to configure',
'',
];
@@ -83,6 +84,7 @@ export function loadClaudeMemEnv(): ClaudeMemEnv {
const result: ClaudeMemEnv = {};
if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY;
if (parsed.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = parsed.ANTHROPIC_BASE_URL;
if (parsed.ANTHROPIC_AUTH_TOKEN) result.ANTHROPIC_AUTH_TOKEN = parsed.ANTHROPIC_AUTH_TOKEN;
if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY;
if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY;
@@ -126,6 +128,13 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
delete updated.ANTHROPIC_BASE_URL;
}
}
if (env.ANTHROPIC_AUTH_TOKEN !== undefined) {
if (env.ANTHROPIC_AUTH_TOKEN) {
updated.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_AUTH_TOKEN;
} else {
delete updated.ANTHROPIC_AUTH_TOKEN;
}
}
if (env.GEMINI_API_KEY !== undefined) {
if (env.GEMINI_API_KEY) {
updated.GEMINI_API_KEY = env.GEMINI_API_KEY;
@@ -171,6 +180,9 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
if (credentials.ANTHROPIC_BASE_URL) {
isolatedEnv.ANTHROPIC_BASE_URL = credentials.ANTHROPIC_BASE_URL;
}
if (credentials.ANTHROPIC_AUTH_TOKEN) {
isolatedEnv.ANTHROPIC_AUTH_TOKEN = credentials.ANTHROPIC_AUTH_TOKEN;
}
if (credentials.GEMINI_API_KEY) {
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
}
@@ -214,10 +226,9 @@ export async function buildIsolatedEnvWithFreshOAuth(
if (!includeCredentials) return isolatedEnv;
// If the user already configured an ANTHROPIC_API_KEY in ~/.claude-mem/.env,
// honor that and skip OAuth lookup entirely. API key auth is preferred when
// explicitly configured because it's stateless and stable.
if (isolatedEnv.ANTHROPIC_API_KEY) {
// If the user already configured explicit Anthropic/gateway credentials in
// ~/.claude-mem/.env, honor those and skip OAuth lookup entirely.
if (isolatedEnv.ANTHROPIC_API_KEY || isolatedEnv.ANTHROPIC_AUTH_TOKEN) {
clearStaleMarker();
return isolatedEnv;
}
@@ -276,10 +287,18 @@ export function hasAnthropicApiKey(): boolean {
return !!env.ANTHROPIC_API_KEY;
}
export function hasAnthropicAuthToken(): boolean {
const env = loadClaudeMemEnv();
return !!env.ANTHROPIC_AUTH_TOKEN;
}
export function getAuthMethodDescription(): string {
if (hasAnthropicApiKey()) {
return 'API key (from ~/.claude-mem/.env)';
}
if (hasAnthropicAuthToken()) {
return 'Gateway auth token (from ~/.claude-mem/.env)';
}
// Note: this is a quick sync hint for logging — the authoritative OAuth
// path is buildIsolatedEnvWithFreshOAuth() which reads the keychain at
// spawn time. process.env may or may not carry a token here.
+1 -1
View File
@@ -74,7 +74,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli', // Default to CLI subscription billing (not API key)
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'subscription', // Default to logged-in Claude SDK auth (not API key)
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users