feat: add support for claude pro and max plans (#327)

This commit is contained in:
jnjpng
2025-12-19 16:26:41 -08:00
committed by GitHub
parent 8c5618ec36
commit f9bffaed81
11 changed files with 1750 additions and 2 deletions

View File

@@ -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,

View File

@@ -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<string, unknown>,
): 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/");

235
src/auth/anthropic-oauth.ts Normal file
View File

@@ -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<string> {
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<AnthropicTokens> {
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<AnthropicTokens> {
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<boolean> {
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<string | null> {
// 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<boolean> {
const token = await getAnthropicAccessToken();
if (!token) {
return false;
}
return validateAnthropicCredentials(token);
}

271
src/auth/callback-server.ts Normal file
View File

@@ -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 `<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 16px;
backdrop-filter: blur(10px);
}
h1 { margin: 0 0 16px 0; font-size: 24px; }
p { margin: 0; opacity: 0.9; }
.checkmark {
font-size: 48px;
margin-bottom: 16px;
}
</style>
</head>
<body>
<div class="container">
<div class="checkmark">✓</div>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to Letta Code.</p>
</div>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>`;
}
/**
* HTML response for OAuth error
*/
function errorHtml(error: string, description?: string): string {
return `<!DOCTYPE html>
<html>
<head>
<title>Authorization Failed</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 16px;
backdrop-filter: blur(10px);
}
h1 { margin: 0 0 16px 0; font-size: 24px; }
p { margin: 0; opacity: 0.9; }
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-details {
margin-top: 16px;
font-size: 14px;
opacity: 0.8;
}
</style>
</head>
<body>
<div class="container">
<div class="error-icon">✗</div>
<h1>Authorization Failed</h1>
<p>${description || error}</p>
<p class="error-details">Error: ${error}</p>
</div>
</body>
</html>`;
}
export class OAuthCallbackServer {
private server: Server<unknown> | null = null;
private pendingCallbacks: Map<string, PendingCallback> = new Map();
private static instance: OAuthCallbackServer | null = null;
static getInstance(): OAuthCallbackServer {
if (!OAuthCallbackServer.instance) {
OAuthCallbackServer.instance = new OAuthCallbackServer();
}
return OAuthCallbackServer.instance;
}
async ensureRunning(): Promise<void> {
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<CallbackData> {
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();

View File

@@ -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<ActiveOverlay>(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" && <HelpDialog onClose={closeOverlay} />}
{/* OAuth Code Dialog - for Claude OAuth connection */}
{activeOverlay === "oauth" && (
<OAuthCodeDialog
onComplete={(success, message) => {
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" && (
<PinDialog

413
src/cli/commands/connect.ts Normal file
View File

@@ -0,0 +1,413 @@
// src/cli/commands/connect.ts
// Command handlers for OAuth connection management
import {
exchangeCodeForTokens,
startAnthropicOAuth,
validateAnthropicCredentials,
} from "../../auth/anthropic-oauth";
import {
ANTHROPIC_PROVIDER_NAME,
createOrUpdateAnthropicProvider,
removeAnthropicProvider,
} from "../../providers/anthropic-provider";
import { settingsManager } from "../../settings-manager";
import type { Buffers, Line } from "../helpers/accumulator";
// tiny helper for unique ids
function uid(prefix: string) {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
// Helper type for command result
type CommandLine = Extract<Line, { kind: "command" }>;
// 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 <code>` to complete the exchange
*/
export async function handleConnect(
ctx: ConnectCommandContext,
msg: string,
): Promise<void> {
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 <code#state>\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<void> {
// 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<void> {
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);
}
}

View File

@@ -209,6 +209,20 @@ export const commands: Record<string, Command> = {
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...";
},
},
};
/**

View File

@@ -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<void>;
};
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<FlowState>("initializing");
const [authUrl, setAuthUrl] = useState<string>("");
const [codeInput, setCodeInput] = useState("");
const [errorMessage, setErrorMessage] = useState<string>("");
const [codeVerifier, setCodeVerifier] = useState<string>("");
const [state, setState] = useState<string>("");
const [availableModels, setAvailableModels] = useState<string[]>([]);
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 (
<Box flexDirection="column" padding={1}>
<Text color={colors.status.processing}>
Starting Claude OAuth flow...
</Text>
</Box>
);
}
if (flowState === "error") {
return (
<Box flexDirection="column" padding={1}>
<Text color="red"> OAuth Error: {errorMessage}</Text>
<Box marginTop={1}>
<Text dimColor>Press any key to close</Text>
</Box>
<WaitForKeyThenClose
onClose={() => {
settingsManager.clearOAuthState();
onComplete(false, `✗ Failed to connect: ${errorMessage}`);
}}
/>
</Box>
);
}
// Model selection UI
if (flowState === "select_model") {
return (
<Box flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text color={colors.approval.header} bold>
[Claude OAuth]
</Text>
<Text color="green"> Connected!</Text>
</Box>
<Box marginBottom={1}>
<Text>Select a model to switch to:</Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
{availableModels.map((model, index) => {
const displayName = model.replace(
`${ANTHROPIC_PROVIDER_NAME}/`,
"",
);
const isSelected = index === selectedModelIndex;
return (
<Box key={model}>
<Text color={isSelected ? colors.approval.header : undefined}>
{isSelected ? " " : " "}
{displayName}
</Text>
</Box>
);
})}
</Box>
<Box marginTop={1}>
<Text dimColor> to select, Enter to confirm, Esc to skip</Text>
</Box>
</Box>
);
}
if (flowState !== "waiting_for_code") {
const statusMessages: Record<string, string> = {
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 (
<Box flexDirection="column" padding={1}>
<Text color={colors.status.processing}>
{statusMessages[flowState]}
</Text>
</Box>
);
}
return (
<Box flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text color={colors.approval.header} bold>
[Claude OAuth]
</Text>
</Box>
<Box marginBottom={1}>
<Text>Opening browser for authorization...</Text>
</Box>
<Box marginBottom={1} flexDirection="column">
<Text dimColor>If browser doesn't open, copy this URL:</Text>
<Text color={colors.link.url}>{authUrl}</Text>
</Box>
<Box marginBottom={1} flexDirection="column">
<Text>
After authorizing, copy the <Text bold>code</Text> value from the
page and paste it below:
</Text>
</Box>
<Box>
<Text color={colors.approval.header}>&gt; </Text>
<PasteAwareTextInput
value={codeInput}
onChange={setCodeInput}
onSubmit={handleSubmit}
placeholder="Paste code here..."
/>
</Box>
<Box marginTop={1}>
<Text dimColor>Enter to submit, Esc to cancel</Text>
</Box>
</Box>
);
},
);
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";

View File

@@ -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",

View File

@@ -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<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
body?: Record<string, unknown>,
): Promise<T> {
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<ProviderResponse[]> {
try {
const response = await providersRequest<ProviderResponse[]>(
"GET",
"/v1/providers",
);
return response;
} catch {
return [];
}
}
/**
* Get the letta-code-claude provider if it exists
*/
export async function getAnthropicProvider(): Promise<ProviderResponse | null> {
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<ProviderResponse> {
return providersRequest<ProviderResponse>("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<ProviderResponse> {
return providersRequest<ProviderResponse>(
"PATCH",
`/v1/providers/${providerId}`,
{
api_key: accessToken,
},
);
}
/**
* Delete the Anthropic provider
*/
export async function deleteAnthropicProvider(
providerId: string,
): Promise<void> {
await providersRequest<void>("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<ProviderResponse> {
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<void> {
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<void> {
const existing = await getAnthropicProvider();
if (existing) {
await deleteAnthropicProvider(existing.id);
}
}

View File

@@ -16,12 +16,26 @@ export interface Settings {
pinnedAgents?: string[]; // Array of agent IDs pinned globally
permissions?: PermissionRules;
env?: Record<string, string>;
// 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<string, string>;
// 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.