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

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