feat: add support for claude pro and max plans (#327)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
235
src/auth/anthropic-oauth.ts
Normal 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
271
src/auth/callback-server.ts
Normal 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();
|
||||
@@ -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
413
src/cli/commands/connect.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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...";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
389
src/cli/components/OAuthCodeDialog.tsx
Normal file
389
src/cli/components/OAuthCodeDialog.tsx
Normal 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}>> </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";
|
||||
@@ -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",
|
||||
|
||||
189
src/providers/anthropic-provider.ts
Normal file
189
src/providers/anthropic-provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user