From f9bffaed81ebc4abe6466d52f83459877b6fb5c9 Mon Sep 17 00:00:00 2001 From: jnjpng Date: Fri, 19 Dec 2025 16:26:41 -0800 Subject: [PATCH] feat: add support for claude pro and max plans (#327) --- src/agent/client.ts | 5 + src/agent/modify.ts | 6 +- src/auth/anthropic-oauth.ts | 235 ++++++++++++++ src/auth/callback-server.ts | 271 ++++++++++++++++ src/cli/App.tsx | 70 +++++ src/cli/commands/connect.ts | 413 +++++++++++++++++++++++++ src/cli/commands/registry.ts | 14 + src/cli/components/OAuthCodeDialog.tsx | 389 +++++++++++++++++++++++ src/cli/helpers/clipboard.ts | 37 +++ src/providers/anthropic-provider.ts | 189 +++++++++++ src/settings-manager.ts | 123 +++++++- 11 files changed, 1750 insertions(+), 2 deletions(-) create mode 100644 src/auth/anthropic-oauth.ts create mode 100644 src/auth/callback-server.ts create mode 100644 src/cli/commands/connect.ts create mode 100644 src/cli/components/OAuthCodeDialog.tsx create mode 100644 src/providers/anthropic-provider.ts diff --git a/src/agent/client.ts b/src/agent/client.ts index 23fa9c6..0b34ddd 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -2,6 +2,7 @@ import { hostname } from "node:os"; import Letta from "@letta-ai/letta-client"; import packageJson from "../../package.json"; import { LETTA_CLOUD_API_URL, refreshAccessToken } from "../auth/oauth"; +import { ensureAnthropicProviderToken } from "../providers/anthropic-provider"; import { settingsManager } from "../settings-manager"; export async function getClient() { @@ -68,6 +69,10 @@ export async function getClient() { process.exit(1); } + // Ensure Anthropic OAuth token is valid and provider is updated + // This checks if token is expired, refreshes it, and updates the provider + await ensureAnthropicProviderToken(); + return new Letta({ apiKey, baseURL, diff --git a/src/agent/modify.ts b/src/agent/modify.ts index dce4cd9..0f02932 100644 --- a/src/agent/modify.ts +++ b/src/agent/modify.ts @@ -7,6 +7,7 @@ import type { OpenAIModelSettings, } from "@letta-ai/letta-client/resources/agents/agents"; import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models"; +import { ANTHROPIC_PROVIDER_NAME } from "../providers/anthropic-provider"; import { getAllLettaToolNames, getToolNames } from "../tools/manager"; import { getClient } from "./client"; @@ -25,7 +26,10 @@ function buildModelSettings( updateArgs?: Record, ): ModelSettings { const isOpenAI = modelHandle.startsWith("openai/"); - const isAnthropic = modelHandle.startsWith("anthropic/"); + // Include our custom Anthropic OAuth provider (claude-pro-max) + const isAnthropic = + modelHandle.startsWith("anthropic/") || + modelHandle.startsWith(`${ANTHROPIC_PROVIDER_NAME}/`); const isGoogleAI = modelHandle.startsWith("google_ai/"); const isGoogleVertex = modelHandle.startsWith("google_vertex/"); const isOpenRouter = modelHandle.startsWith("openrouter/"); diff --git a/src/auth/anthropic-oauth.ts b/src/auth/anthropic-oauth.ts new file mode 100644 index 0000000..39f900c --- /dev/null +++ b/src/auth/anthropic-oauth.ts @@ -0,0 +1,235 @@ +/** + * OAuth 2.0 utilities for Anthropic authentication + * Uses Authorization Code Flow with PKCE + */ + +export const ANTHROPIC_OAUTH_CONFIG = { + clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + authorizationUrl: "https://claude.ai/oauth/authorize", + tokenUrl: "https://console.anthropic.com/v1/oauth/token", + redirectUri: "https://console.anthropic.com/oauth/code/callback", + scope: "org:create_api_key user:profile user:inference", +} as const; + +export interface AnthropicTokens { + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; + scope?: string; +} + +export interface OAuthError { + error: string; + error_description?: string; +} + +/** + * Generate PKCE code verifier (43-128 characters of unreserved URI characters) + */ +export function generateCodeVerifier(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} + +/** + * Generate PKCE code challenge from verifier using SHA-256 + */ +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** + * Generate cryptographically secure state parameter (32-byte hex) + */ +export function generateState(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Base64 URL encode (RFC 4648) + */ +function base64UrlEncode(buffer: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...buffer)); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Generate PKCE code verifier and challenge + */ +export async function generatePKCE(): Promise<{ + codeVerifier: string; + codeChallenge: string; +}> { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + return { codeVerifier, codeChallenge }; +} + +/** + * Start OAuth flow - returns authorization URL and PKCE values + */ +export async function startAnthropicOAuth(): Promise<{ + authorizationUrl: string; + state: string; + codeVerifier: string; +}> { + const state = generateState(); + const { codeVerifier, codeChallenge } = await generatePKCE(); + + const params = new URLSearchParams({ + response_type: "code", + client_id: ANTHROPIC_OAUTH_CONFIG.clientId, + redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri, + scope: ANTHROPIC_OAUTH_CONFIG.scope, + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + const authorizationUrl = `${ANTHROPIC_OAUTH_CONFIG.authorizationUrl}?${params.toString()}`; + + return { + authorizationUrl, + state, + codeVerifier, + }; +} + +/** + * Exchange authorization code for tokens + */ +export async function exchangeCodeForTokens( + code: string, + codeVerifier: string, + state: string, +): Promise { + const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: ANTHROPIC_OAUTH_CONFIG.clientId, + code, + state, + redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + // Show full response for debugging + throw new Error( + `Failed to exchange code for tokens (HTTP ${response.status}): ${errorText}`, + ); + } + + return (await response.json()) as AnthropicTokens; +} + +/** + * Refresh an access token using a refresh token + */ +export async function refreshAnthropicToken( + refreshToken: string, +): Promise { + const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: ANTHROPIC_OAUTH_CONFIG.clientId, + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: "unknown_error", + error_description: `HTTP ${response.status}`, + }))) as OAuthError; + throw new Error( + `Failed to refresh access token: ${error.error_description || error.error}`, + ); + } + + return (await response.json()) as AnthropicTokens; +} + +/** + * Validate credentials by making a test API call + * OAuth tokens require the anthropic-beta header + */ +export async function validateAnthropicCredentials( + accessToken: string, +): Promise { + try { + // Use the models endpoint to validate the token + const response = await fetch("https://api.anthropic.com/v1/models", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + }, + }); + + return response.ok; + } catch { + return false; + } +} + +/** + * Get a valid Anthropic access token, refreshing if necessary + * Returns null if no OAuth tokens are configured + */ +export async function getAnthropicAccessToken(): Promise { + // Lazy import to avoid circular dependencies + const { settingsManager } = await import("../settings-manager"); + + const tokens = settingsManager.getAnthropicTokens(); + if (!tokens) { + return null; + } + + // Check if token is expired or about to expire (within 5 minutes) + const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000; + if (tokens.expires_at < fiveMinutesFromNow && tokens.refresh_token) { + try { + const newTokens = await refreshAnthropicToken(tokens.refresh_token); + settingsManager.storeAnthropicTokens(newTokens); + return newTokens.access_token; + } catch (error) { + console.error("Failed to refresh Anthropic access token:", error); + // Return existing token even if refresh failed - it might still work + return tokens.access_token; + } + } + + return tokens.access_token; +} + +/** + * Check if Anthropic OAuth is configured and valid + */ +export async function hasValidAnthropicAuth(): Promise { + const token = await getAnthropicAccessToken(); + if (!token) { + return false; + } + return validateAnthropicCredentials(token); +} diff --git a/src/auth/callback-server.ts b/src/auth/callback-server.ts new file mode 100644 index 0000000..b809743 --- /dev/null +++ b/src/auth/callback-server.ts @@ -0,0 +1,271 @@ +/** + * Local HTTP callback server for OAuth flows + * Listens on port 19876 for OAuth redirects + */ + +import type { Server } from "bun"; + +export interface CallbackData { + code: string; + state: string; + error?: string; + error_description?: string; +} + +interface PendingCallback { + resolve: (data: CallbackData) => void; + reject: (error: Error) => void; + timeout: Timer; +} + +/** + * HTML response for successful OAuth callback + */ +function successHtml(): string { + return ` + + + Authorization Successful + + + +
+
+

Authorization Successful!

+

You can close this window and return to Letta Code.

+
+ + +`; +} + +/** + * HTML response for OAuth error + */ +function errorHtml(error: string, description?: string): string { + return ` + + + Authorization Failed + + + +
+
+

Authorization Failed

+

${description || error}

+

Error: ${error}

+
+ +`; +} + +export class OAuthCallbackServer { + private server: Server | null = null; + private pendingCallbacks: Map = new Map(); + private static instance: OAuthCallbackServer | null = null; + + static getInstance(): OAuthCallbackServer { + if (!OAuthCallbackServer.instance) { + OAuthCallbackServer.instance = new OAuthCallbackServer(); + } + return OAuthCallbackServer.instance; + } + + async ensureRunning(): Promise { + if (this.server) return; + + const self = this; + + this.server = Bun.serve({ + port: 19876, + fetch(req) { + const url = new URL(req.url); + + if (url.pathname === "/callback") { + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + // Handle error from OAuth provider + if (error) { + const callbackData: CallbackData = { + code: "", + state: state || "", + error, + error_description: errorDescription || undefined, + }; + + // Resolve pending callback with error + if (state) { + const pending = self.pendingCallbacks.get(state); + if (pending) { + clearTimeout(pending.timeout); + pending.resolve(callbackData); + self.pendingCallbacks.delete(state); + } + } + + return new Response( + errorHtml(error, errorDescription || undefined), + { + headers: { "Content-Type": "text/html" }, + }, + ); + } + + // Validate required params + if (!code || !state) { + return new Response( + errorHtml("invalid_request", "Missing code or state parameter"), + { + status: 400, + headers: { "Content-Type": "text/html" }, + }, + ); + } + + const callbackData: CallbackData = { + code, + state, + }; + + // Resolve pending callback + const pending = self.pendingCallbacks.get(state); + if (pending) { + clearTimeout(pending.timeout); + pending.resolve(callbackData); + self.pendingCallbacks.delete(state); + } + + return new Response(successHtml(), { + headers: { "Content-Type": "text/html" }, + }); + } + + // Health check endpoint + if (url.pathname === "/health") { + return new Response("OK", { status: 200 }); + } + + return new Response("Not Found", { status: 404 }); + }, + }); + } + + /** + * Wait for OAuth callback with matching state + * @param state The state parameter to match + * @param timeout Timeout in milliseconds (default: 5 minutes) + */ + async waitForCallback( + state: string, + timeout: number = 300000, + ): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingCallbacks.delete(state); + reject( + new Error(`OAuth callback timeout after ${timeout / 1000} seconds`), + ); + }, timeout); + + this.pendingCallbacks.set(state, { + resolve, + reject, + timeout: timeoutId, + }); + }); + } + + /** + * Cancel a pending callback + */ + cancelPending(state: string): void { + const pending = this.pendingCallbacks.get(state); + if (pending) { + clearTimeout(pending.timeout); + pending.reject(new Error("Callback cancelled")); + this.pendingCallbacks.delete(state); + } + } + + /** + * Stop the callback server + */ + stop(): void { + // Reject all pending callbacks + for (const [state, pending] of this.pendingCallbacks) { + clearTimeout(pending.timeout); + pending.reject(new Error("Server stopped")); + this.pendingCallbacks.delete(state); + } + + this.server?.stop(); + this.server = null; + } + + /** + * Check if server is running + */ + isRunning(): boolean { + return this.server !== null; + } +} + +// Export singleton instance +export const oauthCallbackServer = OAuthCallbackServer.getInstance(); diff --git a/src/cli/App.tsx b/src/cli/App.tsx index f831617..ae0c3b3 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -60,6 +60,7 @@ import { McpSelector } from "./components/McpSelector"; import { MemoryViewer } from "./components/MemoryViewer"; import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; +import { OAuthCodeDialog } from "./components/OAuthCodeDialog"; import { PinDialog, validateAgentName } from "./components/PinDialog"; import { PlanModeDialog } from "./components/PlanModeDialog"; import { ProfileSelector } from "./components/ProfileSelector"; @@ -418,6 +419,7 @@ export default function App({ | "pin" | "mcp" | "help" + | "oauth" | null; const [activeOverlay, setActiveOverlay] = useState(null); const closeOverlay = useCallback(() => setActiveOverlay(null), []); @@ -1794,6 +1796,45 @@ export default function App({ return { submitted: true }; } + // Special handling for /connect command - OAuth connection + if (msg.trim().startsWith("/connect")) { + const parts = msg.trim().split(/\s+/); + const provider = parts[1]?.toLowerCase(); + const hasCode = parts.length > 2; + + // If no code provided and provider is claude, show the OAuth dialog + if (provider === "claude" && !hasCode) { + setActiveOverlay("oauth"); + return { submitted: true }; + } + + // Otherwise (with code or invalid provider), use existing handler + const { handleConnect } = await import("./commands/connect"); + await handleConnect( + { + buffersRef, + refreshDerived, + setCommandRunning, + }, + msg, + ); + return { submitted: true }; + } + + // Special handling for /disconnect command - remove OAuth connection + if (msg.trim().startsWith("/disconnect")) { + const { handleDisconnect } = await import("./commands/connect"); + await handleDisconnect( + { + buffersRef, + refreshDerived, + setCommandRunning, + }, + msg, + ); + return { submitted: true }; + } + // Special handling for /help command - opens help dialog if (trimmed === "/help") { setActiveOverlay("help"); @@ -4664,6 +4705,35 @@ Plan file path: ${planFilePath}`; {/* Help Dialog - conditionally mounted as overlay */} {activeOverlay === "help" && } + {/* OAuth Code Dialog - for Claude OAuth connection */} + {activeOverlay === "oauth" && ( + { + closeOverlay(); + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: "/connect claude", + output: message, + phase: "finished", + success, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + }} + onCancel={closeOverlay} + onModelSwitch={async (modelHandle: string) => { + const { updateAgentLLMConfig } = await import( + "../agent/modify" + ); + await updateAgentLLMConfig(agentId, modelHandle); + // Update current model display + setCurrentModelId(modelHandle); + }} + /> + )} + {/* Pin Dialog - for naming agent before pinning */} {activeOverlay === "pin" && ( ; + +// Context passed to connect handlers +export interface ConnectCommandContext { + buffersRef: { current: Buffers }; + refreshDerived: () => void; + setCommandRunning: (running: boolean) => void; +} + +// Helper to add a command result to buffers +function addCommandResult( + buffersRef: { current: Buffers }, + refreshDerived: () => void, + input: string, + output: string, + success: boolean, + phase: "running" | "finished" = "finished", +): string { + const cmdId = uid("cmd"); + const line: CommandLine = { + kind: "command", + id: cmdId, + input, + output, + phase, + ...(phase === "finished" && { success }), + }; + buffersRef.current.byId.set(cmdId, line); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return cmdId; +} + +// Helper to update an existing command result +function updateCommandResult( + buffersRef: { current: Buffers }, + refreshDerived: () => void, + cmdId: string, + input: string, + output: string, + success: boolean, + phase: "running" | "finished" = "finished", +): void { + const line: CommandLine = { + kind: "command", + id: cmdId, + input, + output, + phase, + ...(phase === "finished" && { success }), + }; + buffersRef.current.byId.set(cmdId, line); + refreshDerived(); +} + +/** + * Handle /connect command + * Usage: /connect claude [code] + * + * Flow: + * 1. User runs `/connect claude` - opens browser for authorization + * 2. User authorizes on claude.ai, gets redirected to Anthropic's callback page + * 3. User copies the authorization code from the URL + * 4. User runs `/connect claude ` to complete the exchange + */ +export async function handleConnect( + ctx: ConnectCommandContext, + msg: string, +): Promise { + const parts = msg.trim().split(/\s+/); + const provider = parts[1]?.toLowerCase(); + // Join all remaining parts in case the code#state got split across lines + const authCode = parts.slice(2).join(""); // Optional authorization code + + // Validate provider argument + if (!provider) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Usage: /connect claude\n\nConnect to Claude via OAuth to authenticate without an API key.", + false, + ); + return; + } + + if (provider !== "claude") { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `Error: Unknown provider "${provider}"\n\nCurrently only 'claude' provider is supported.\nUsage: /connect claude`, + false, + ); + return; + } + + // If authorization code is provided, complete the OAuth flow + if (authCode && authCode.length > 0) { + await completeOAuthFlow(ctx, msg, authCode); + return; + } + + // Check if already connected + if ( + settingsManager.hasAnthropicOAuth() && + !settingsManager.isAnthropicTokenExpired() + ) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Already connected to Claude via OAuth.\n\nUse /disconnect to remove the current connection first.", + false, + ); + return; + } + + // Start the OAuth flow (step 1) + ctx.setCommandRunning(true); + + try { + // 1. Start OAuth flow - generate PKCE and authorization URL + const { authorizationUrl, state, codeVerifier } = + await startAnthropicOAuth(); + + // 2. Store state for validation when user returns with code + settingsManager.storeOAuthState(state, codeVerifier, "anthropic"); + + // 3. Try to open browser + let browserOpened = false; + try { + const { default: open } = await import("open"); + const subprocess = await open(authorizationUrl, { wait: false }); + browserOpened = true; + // Handle errors from the spawned process (e.g., xdg-open not found in containers) + subprocess.on("error", () => { + // Silently ignore - user can still manually visit the URL + }); + } catch { + // If auto-open fails, user can still manually visit the URL + } + + // 4. Show instructions + const browserMsg = browserOpened + ? "Opening browser for authorization..." + : "Please open the following URL in your browser:"; + + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `${browserMsg}\n\n${authorizationUrl}\n\n` + + "After authorizing, you'll be redirected to a page showing: code#state\n" + + "Copy the entire value and run:\n\n" + + " /connect claude \n\n" + + "Example: /connect claude abc123...#def456...", + true, + ); + } catch (error) { + // Clear any partial state + settingsManager.clearOAuthState(); + + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `✗ Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + false, + ); + } finally { + ctx.setCommandRunning(false); + } +} + +/** + * Complete OAuth flow after user provides authorization code + * Accepts either: + * - Just the code: "n3nzU6B7gMep..." + * - Code#state format: "n3nzU6B7gMep...#9ba626d8..." + */ +async function completeOAuthFlow( + ctx: ConnectCommandContext, + msg: string, + authCodeInput: string, +): Promise { + // Show initial status + const cmdId = addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Exchanging authorization code for tokens...", + true, + "running", + ); + + ctx.setCommandRunning(true); + + try { + // 1. Get stored OAuth state + const storedState = settingsManager.getOAuthState(); + if (!storedState) { + throw new Error( + "No pending OAuth flow found. Please run '/connect claude' first to start the authorization.", + ); + } + + // 2. Check if state is too old (5 minute timeout) + const fiveMinutes = 5 * 60 * 1000; + if (Date.now() - storedState.timestamp > fiveMinutes) { + settingsManager.clearOAuthState(); + throw new Error( + "OAuth session expired. Please run '/connect claude' again to start a new authorization.", + ); + } + + // 3. Parse code#state format if provided + let authCode = authCodeInput; + let stateFromInput: string | undefined; + if (authCodeInput.includes("#")) { + const [code, stateVal] = authCodeInput.split("#"); + authCode = code ?? authCodeInput; + stateFromInput = stateVal; + // Validate state matches what we stored + if (stateVal && stateVal !== storedState.state) { + throw new Error( + "State mismatch - the authorization may have been tampered with. Please try again.", + ); + } + } + + // Use state from input if provided, otherwise use stored state + const stateToUse = stateFromInput || storedState.state; + + // 4. Exchange code for tokens + const tokens = await exchangeCodeForTokens( + authCode, + storedState.codeVerifier, + stateToUse, + ); + + // 4. Update status + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + "Validating credentials...", + true, + "running", + ); + + // 5. Validate tokens work + const isValid = await validateAnthropicCredentials(tokens.access_token); + if (!isValid) { + throw new Error( + "Token validation failed - the token may not have the required permissions.", + ); + } + + // 6. Store tokens locally + settingsManager.storeAnthropicTokens(tokens); + + // 7. Update status for provider creation + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + "Creating Anthropic provider...", + true, + "running", + ); + + // 8. Create or update provider in Letta with the access token + await createOrUpdateAnthropicProvider(tokens.access_token); + + // 9. Clear OAuth state + settingsManager.clearOAuthState(); + + // 10. Success! + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✓ Successfully connected to Claude via OAuth!\n\n` + + `Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` + + `Your OAuth tokens are stored securely in ~/.letta/settings.json`, + true, + "finished", + ); + } catch (error) { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✗ Failed to connect: ${error instanceof Error ? error.message : String(error)}`, + false, + "finished", + ); + } finally { + ctx.setCommandRunning(false); + } +} + +/** + * Handle /disconnect command + * Usage: /disconnect [claude] + */ +export async function handleDisconnect( + ctx: ConnectCommandContext, + msg: string, +): Promise { + const parts = msg.trim().split(/\s+/); + const provider = parts[1]?.toLowerCase(); + + // If no provider specified, show help or assume claude + if (provider && provider !== "claude") { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `Error: Unknown provider "${provider}"\n\nCurrently only 'claude' provider is supported.\nUsage: /disconnect`, + false, + ); + return; + } + + // Check if connected + if (!settingsManager.hasAnthropicOAuth()) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Not currently connected to Claude via OAuth.\n\nUse /connect claude to authenticate.", + false, + ); + return; + } + + // Show running status + const cmdId = addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Disconnecting from Claude OAuth...", + true, + "running", + ); + + ctx.setCommandRunning(true); + + try { + // Remove provider from Letta + await removeAnthropicProvider(); + + // Clear local tokens + settingsManager.clearAnthropicOAuth(); + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✓ Disconnected from Claude OAuth.\n\n` + + `Provider '${ANTHROPIC_PROVIDER_NAME}' removed from Letta.\n` + + `Your OAuth tokens have been removed from ~/.letta/settings.json`, + true, + "finished", + ); + } catch (error) { + // Still clear local tokens even if provider removal fails + settingsManager.clearAnthropicOAuth(); + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✓ Disconnected from Claude OAuth.\n\n` + + `Warning: Failed to remove provider from Letta: ${error instanceof Error ? error.message : String(error)}\n` + + `Your local OAuth tokens have been removed.`, + true, + "finished", + ); + } finally { + ctx.setCommandRunning(false); + } +} diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 04c93e8..913c654 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -209,6 +209,20 @@ export const commands: Record = { return "Opening help..."; }, }, + "/connect": { + desc: "Connect to Claude via OAuth (/connect claude)", + handler: () => { + // Handled specially in App.tsx + return "Initiating OAuth connection..."; + }, + }, + "/disconnect": { + desc: "Disconnect from Claude OAuth", + handler: () => { + // Handled specially in App.tsx + return "Disconnecting..."; + }, + }, }; /** diff --git a/src/cli/components/OAuthCodeDialog.tsx b/src/cli/components/OAuthCodeDialog.tsx new file mode 100644 index 0000000..4712731 --- /dev/null +++ b/src/cli/components/OAuthCodeDialog.tsx @@ -0,0 +1,389 @@ +import { Box, Text, useInput } from "ink"; +import { memo, useEffect, useState } from "react"; +import { + exchangeCodeForTokens, + startAnthropicOAuth, + validateAnthropicCredentials, +} from "../../auth/anthropic-oauth"; +import { + ANTHROPIC_PROVIDER_NAME, + createOrUpdateAnthropicProvider, +} from "../../providers/anthropic-provider"; +import { settingsManager } from "../../settings-manager"; +import { colors } from "./colors"; +import { PasteAwareTextInput } from "./PasteAwareTextInput"; + +type Props = { + onComplete: (success: boolean, message: string) => void; + onCancel: () => void; + onModelSwitch?: (modelHandle: string) => Promise; +}; + +type FlowState = + | "initializing" + | "waiting_for_code" + | "exchanging" + | "validating" + | "creating_provider" + | "fetching_models" + | "select_model" + | "switching_model" + | "success" + | "error"; + +export const OAuthCodeDialog = memo( + ({ onComplete, onCancel, onModelSwitch }: Props) => { + const [flowState, setFlowState] = useState("initializing"); + const [authUrl, setAuthUrl] = useState(""); + const [codeInput, setCodeInput] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [codeVerifier, setCodeVerifier] = useState(""); + const [state, setState] = useState(""); + const [availableModels, setAvailableModels] = useState([]); + const [selectedModelIndex, setSelectedModelIndex] = useState(0); + + // Initialize OAuth flow on mount + useEffect(() => { + const initFlow = async () => { + try { + // Check if already connected + if ( + settingsManager.hasAnthropicOAuth() && + !settingsManager.isAnthropicTokenExpired() + ) { + onComplete( + false, + "Already connected to Claude via OAuth.\n\nUse /disconnect to remove the current connection first.", + ); + return; + } + + // Start OAuth flow + const { + authorizationUrl, + state: oauthState, + codeVerifier: verifier, + } = await startAnthropicOAuth(); + + // Store state for validation + settingsManager.storeOAuthState(oauthState, verifier, "anthropic"); + + setAuthUrl(authorizationUrl); + setCodeVerifier(verifier); + setState(oauthState); + setFlowState("waiting_for_code"); + + // Try to open browser + try { + const { default: open } = await import("open"); + const subprocess = await open(authorizationUrl, { wait: false }); + subprocess.on("error", () => { + // Silently ignore - user can manually visit URL + }); + } catch { + // If auto-open fails, user can still manually visit the URL + } + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : String(error), + ); + setFlowState("error"); + } + }; + + initFlow(); + }, [onComplete]); + + // Handle keyboard input + useInput((_input, key) => { + if (key.escape && flowState === "waiting_for_code") { + settingsManager.clearOAuthState(); + onCancel(); + } + + // Handle model selection navigation + if (flowState === "select_model") { + if (key.upArrow) { + setSelectedModelIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedModelIndex((prev) => + Math.min(availableModels.length - 1, prev + 1), + ); + } else if (key.return && onModelSwitch) { + // Select current model + const selectedModel = availableModels[selectedModelIndex]; + if (selectedModel) { + handleModelSelection(selectedModel); + } + } else if (key.escape) { + // Skip model selection + skipModelSelection(); + } + } + }); + + // Handle model selection + const handleModelSelection = async (modelHandle: string) => { + if (!onModelSwitch) return; + + setFlowState("switching_model"); + try { + await onModelSwitch(modelHandle); + setFlowState("success"); + onComplete( + true, + `✓ Successfully connected to Claude via OAuth!\n\n` + + `Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` + + `Switched to model: ${modelHandle.replace(`${ANTHROPIC_PROVIDER_NAME}/`, "")}`, + ); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : String(error)); + setFlowState("error"); + } + }; + + // Skip model selection + const skipModelSelection = () => { + setFlowState("success"); + onComplete( + true, + `✓ Successfully connected to Claude via OAuth!\n\n` + + `Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` + + `Your OAuth tokens are stored securely in ~/.letta/settings.json\n` + + `Use /model to switch to a Claude model.`, + ); + }; + + // Handle code submission + const handleSubmit = async (input: string) => { + if (!input.trim()) return; + + try { + setFlowState("exchanging"); + + // Parse code#state format + let authCode = input.trim(); + let stateFromInput: string | undefined; + + if (authCode.includes("#")) { + const [code, inputState] = authCode.split("#"); + authCode = code ?? input.trim(); + stateFromInput = inputState; + + // Validate state matches + if (stateFromInput && stateFromInput !== state) { + throw new Error( + "State mismatch - the authorization may have been tampered with. Please try again.", + ); + } + } + + const stateToUse = stateFromInput || state; + + // Exchange code for tokens + const tokens = await exchangeCodeForTokens( + authCode, + codeVerifier, + stateToUse, + ); + + setFlowState("validating"); + + // Validate tokens + const isValid = await validateAnthropicCredentials(tokens.access_token); + if (!isValid) { + throw new Error( + "Token validation failed - the token may not have the required permissions.", + ); + } + + // Store tokens locally + settingsManager.storeAnthropicTokens(tokens); + + setFlowState("creating_provider"); + + // Create/update provider in Letta + await createOrUpdateAnthropicProvider(tokens.access_token); + + // Clear OAuth state + settingsManager.clearOAuthState(); + + // If we have a model switch handler, try to fetch available models + if (onModelSwitch) { + setFlowState("fetching_models"); + try { + const { getAvailableModelHandles } = await import( + "../../agent/available-models" + ); + const result = await getAvailableModelHandles({ + forceRefresh: true, + }); + + // Filter to only claude-pro-max models + const claudeModels = Array.from(result.handles) + .filter((h) => h.startsWith(`${ANTHROPIC_PROVIDER_NAME}/`)) + .sort(); + + if (claudeModels.length > 0) { + setAvailableModels(claudeModels); + setFlowState("select_model"); + return; // Don't complete yet, wait for model selection + } + } catch { + // If fetching models fails, just complete without selection + } + } + + setFlowState("success"); + onComplete( + true, + `✓ Successfully connected to Claude via OAuth!\n\n` + + `Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` + + `Your OAuth tokens are stored securely in ~/.letta/settings.json\n` + + `Use /model to switch to a Claude model.`, + ); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : String(error)); + setFlowState("error"); + } + }; + + if (flowState === "initializing") { + return ( + + + Starting Claude OAuth flow... + + + ); + } + + if (flowState === "error") { + return ( + + ✗ OAuth Error: {errorMessage} + + Press any key to close + + { + settingsManager.clearOAuthState(); + onComplete(false, `✗ Failed to connect: ${errorMessage}`); + }} + /> + + ); + } + + // Model selection UI + if (flowState === "select_model") { + return ( + + + + [Claude OAuth] + + Connected! + + + + Select a model to switch to: + + + + {availableModels.map((model, index) => { + const displayName = model.replace( + `${ANTHROPIC_PROVIDER_NAME}/`, + "", + ); + const isSelected = index === selectedModelIndex; + return ( + + + {isSelected ? "› " : " "} + {displayName} + + + ); + })} + + + + ↑↓ to select, Enter to confirm, Esc to skip + + + ); + } + + if (flowState !== "waiting_for_code") { + const statusMessages: Record = { + exchanging: "Exchanging authorization code for tokens...", + validating: "Validating credentials...", + creating_provider: "Creating Claude provider...", + fetching_models: "Fetching available models...", + switching_model: "Switching model...", + success: "Success!", + }; + + return ( + + + {statusMessages[flowState]} + + + ); + } + + return ( + + + + [Claude OAuth] + + + + + Opening browser for authorization... + + + + If browser doesn't open, copy this URL: + {authUrl} + + + + + After authorizing, copy the code value from the + page and paste it below: + + + + + > + + + + + Enter to submit, Esc to cancel + + + ); + }, +); + +OAuthCodeDialog.displayName = "OAuthCodeDialog"; + +// Helper component to wait for any key press then close +const WaitForKeyThenClose = memo(({ onClose }: { onClose: () => void }) => { + useInput(() => { + onClose(); + }); + return null; +}); + +WaitForKeyThenClose.displayName = "WaitForKeyThenClose"; diff --git a/src/cli/helpers/clipboard.ts b/src/cli/helpers/clipboard.ts index cbda9ae..926b8bf 100644 --- a/src/cli/helpers/clipboard.ts +++ b/src/cli/helpers/clipboard.ts @@ -4,6 +4,43 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { basename, extname, isAbsolute, resolve } from "node:path"; import { allocateImage } from "./pasteRegistry"; +/** + * Copy text to system clipboard + * Returns true if successful, false otherwise + */ +export function copyToClipboard(text: string): boolean { + try { + if (process.platform === "darwin") { + execFileSync("pbcopy", [], { input: text, encoding: "utf8" }); + return true; + } else if (process.platform === "win32") { + execFileSync("clip", [], { input: text, encoding: "utf8" }); + return true; + } else { + // Linux - try xclip first, then xsel + try { + execFileSync("xclip", ["-selection", "clipboard"], { + input: text, + encoding: "utf8", + }); + return true; + } catch { + try { + execFileSync("xsel", ["--clipboard", "--input"], { + input: text, + encoding: "utf8", + }); + return true; + } catch { + return false; + } + } + } + } catch { + return false; + } +} + const IMAGE_EXTS = new Set([ ".png", ".jpg", diff --git a/src/providers/anthropic-provider.ts b/src/providers/anthropic-provider.ts new file mode 100644 index 0000000..3f05cb2 --- /dev/null +++ b/src/providers/anthropic-provider.ts @@ -0,0 +1,189 @@ +/** + * Direct API calls to Letta for managing Anthropic provider + * Bypasses SDK since it doesn't expose providers API + */ + +import { LETTA_CLOUD_API_URL } from "../auth/oauth"; +import { settingsManager } from "../settings-manager"; + +// Provider name constant for letta-code's Anthropic OAuth provider +export const ANTHROPIC_PROVIDER_NAME = "claude-pro-max"; + +interface ProviderResponse { + id: string; + name: string; + provider_type: string; + api_key?: string; + base_url?: string; +} + +/** + * Get the Letta API base URL and auth token + */ +function getLettaConfig(): { baseUrl: string; apiKey: string } { + const settings = settingsManager.getSettings(); + const baseUrl = + process.env.LETTA_BASE_URL || + settings.env?.LETTA_BASE_URL || + LETTA_CLOUD_API_URL; + const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY || ""; + return { baseUrl, apiKey }; +} + +/** + * Make a request to the Letta providers API + */ +async function providersRequest( + method: "GET" | "POST" | "PATCH" | "DELETE", + path: string, + body?: Record, +): Promise { + const { baseUrl, apiKey } = getLettaConfig(); + const url = `${baseUrl}${path}`; + + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Letta-Source": "letta-code", + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Provider API error (${response.status}): ${errorText}`); + } + + // Handle empty responses (e.g., DELETE) + const text = await response.text(); + if (!text) { + return {} as T; + } + return JSON.parse(text) as T; +} + +/** + * List all providers to find if our provider exists + */ +export async function listProviders(): Promise { + try { + const response = await providersRequest( + "GET", + "/v1/providers", + ); + return response; + } catch { + return []; + } +} + +/** + * Get the letta-code-claude provider if it exists + */ +export async function getAnthropicProvider(): Promise { + const providers = await listProviders(); + return providers.find((p) => p.name === ANTHROPIC_PROVIDER_NAME) || null; +} + +/** + * Create a new Anthropic provider with OAuth access token + */ +export async function createAnthropicProvider( + accessToken: string, +): Promise { + return providersRequest("POST", "/v1/providers", { + name: ANTHROPIC_PROVIDER_NAME, + provider_type: "anthropic", + api_key: accessToken, + }); +} + +/** + * Update an existing Anthropic provider with new access token + */ +export async function updateAnthropicProvider( + providerId: string, + accessToken: string, +): Promise { + return providersRequest( + "PATCH", + `/v1/providers/${providerId}`, + { + api_key: accessToken, + }, + ); +} + +/** + * Delete the Anthropic provider + */ +export async function deleteAnthropicProvider( + providerId: string, +): Promise { + await providersRequest("DELETE", `/v1/providers/${providerId}`); +} + +/** + * Create or update the Anthropic provider with OAuth access token + * This is the main function called after successful /connect + */ +export async function createOrUpdateAnthropicProvider( + accessToken: string, +): Promise { + const existing = await getAnthropicProvider(); + + if (existing) { + // Update existing provider with new token + return updateAnthropicProvider(existing.id, accessToken); + } else { + // Create new provider + return createAnthropicProvider(accessToken); + } +} + +/** + * Ensure the Anthropic provider has a valid (non-expired) token + * Call this before making requests that use the provider + */ +export async function ensureAnthropicProviderToken(): Promise { + const settings = settingsManager.getSettings(); + const tokens = settings.anthropicOAuth; + + if (!tokens) { + // No Anthropic OAuth configured, nothing to do + return; + } + + // Check if token is expired or about to expire (within 5 minutes) + const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000; + if (tokens.expires_at < fiveMinutesFromNow && tokens.refresh_token) { + // Token is expired or about to expire, refresh it + const { refreshAnthropicToken } = await import("../auth/anthropic-oauth"); + + try { + const newTokens = await refreshAnthropicToken(tokens.refresh_token); + settingsManager.storeAnthropicTokens(newTokens); + + // Update the provider with the new access token + const existing = await getAnthropicProvider(); + if (existing) { + await updateAnthropicProvider(existing.id, newTokens.access_token); + } + } catch (error) { + console.error("Failed to refresh Anthropic access token:", error); + // Continue with existing token, it might still work + } + } +} + +/** + * Remove the Anthropic provider (called on /disconnect) + */ +export async function removeAnthropicProvider(): Promise { + const existing = await getAnthropicProvider(); + if (existing) { + await deleteAnthropicProvider(existing.id); + } +} diff --git a/src/settings-manager.ts b/src/settings-manager.ts index 9a0ad59..ccc614c 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -16,12 +16,26 @@ export interface Settings { pinnedAgents?: string[]; // Array of agent IDs pinned globally permissions?: PermissionRules; env?: Record; - // OAuth token management + // Letta Cloud OAuth token management refreshToken?: string; tokenExpiresAt?: number; // Unix timestamp in milliseconds deviceId?: string; // Tool upsert cache: maps serverUrl -> hash of upserted tools toolUpsertHashes?: Record; + // Anthropic OAuth + anthropicOAuth?: { + access_token: string; + refresh_token?: string; + expires_at: number; // Unix timestamp in milliseconds + scope?: string; + }; + // Pending OAuth state (for PKCE flow) + oauthState?: { + state: string; + codeVerifier: string; + provider: "anthropic"; + timestamp: number; + }; } export interface ProjectSettings { @@ -597,6 +611,113 @@ class SettingsManager { return exists(dirPath); } + // ===================================================================== + // Anthropic OAuth Management + // ===================================================================== + + /** + * Store Anthropic OAuth tokens + */ + storeAnthropicTokens(tokens: { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; + }): void { + this.updateSettings({ + anthropicOAuth: { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: Date.now() + tokens.expires_in * 1000, + scope: tokens.scope, + }, + }); + } + + /** + * Get Anthropic OAuth tokens (returns null if not set or expired) + */ + getAnthropicTokens(): Settings["anthropicOAuth"] | null { + const settings = this.getSettings(); + if (!settings.anthropicOAuth) return null; + return settings.anthropicOAuth; + } + + /** + * Check if Anthropic OAuth tokens are expired or about to expire + * Returns true if token expires within the next 5 minutes + */ + isAnthropicTokenExpired(): boolean { + const tokens = this.getAnthropicTokens(); + if (!tokens) return true; + + const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000; + return tokens.expires_at < fiveMinutesFromNow; + } + + /** + * Check if Anthropic OAuth is configured + */ + hasAnthropicOAuth(): boolean { + return !!this.getAnthropicTokens(); + } + + /** + * Clear Anthropic OAuth tokens and state + */ + clearAnthropicOAuth(): void { + const settings = this.getSettings(); + const { anthropicOAuth: _, oauthState: __, ...rest } = settings; + this.settings = { ...DEFAULT_SETTINGS, ...rest }; + this.persistSettings().catch((error) => { + console.error( + "Failed to persist settings after clearing Anthropic OAuth:", + error, + ); + }); + } + + /** + * Store OAuth state for pending authorization + */ + storeOAuthState( + state: string, + codeVerifier: string, + provider: "anthropic", + ): void { + this.updateSettings({ + oauthState: { + state, + codeVerifier, + provider, + timestamp: Date.now(), + }, + }); + } + + /** + * Get pending OAuth state + */ + getOAuthState(): Settings["oauthState"] | null { + const settings = this.getSettings(); + return settings.oauthState || null; + } + + /** + * Clear pending OAuth state + */ + clearOAuthState(): void { + const settings = this.getSettings(); + const { oauthState: _, ...rest } = settings; + this.settings = { ...DEFAULT_SETTINGS, ...rest }; + this.persistSettings().catch((error) => { + console.error( + "Failed to persist settings after clearing OAuth state:", + error, + ); + }); + } + /** * Wait for all pending writes to complete. * Useful in tests to ensure writes finish before cleanup.