Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
15 kB · 416 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417import { feature } from 'bun:bundle'import { randomBytes } from 'crypto'import { execa } from 'execa'import { basename, extname, isAbsolute, join } from 'path'import { IMAGE_MAX_HEIGHT, IMAGE_MAX_WIDTH, IMAGE_TARGET_RAW_SIZE,} from '../constants/apiLimits.js'import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'import { getImageProcessor } from '../tools/FileReadTool/imageProcessor.js'import { logForDebugging } from './debug.js'import { execFileNoThrowWithCwd } from './execFileNoThrow.js'import { getFsImplementation } from './fsOperations.js'import { detectImageFormatFromBase64, type ImageDimensions, maybeResizeAndDownsampleImageBuffer,} from './imageResizer.js'import { logError } from './log.js'
// Native NSPasteboard reader. GrowthBook gate tengu_collage_kaleidoscope is// a kill switch (default on). Falls through to osascript when off.// The gate string is inlined at each callsite INSIDE the feature() condition// — module-scope helpers are NOT tree-shaken (see docs/feature-gating.md).
type SupportedPlatform = 'darwin' | 'linux' | 'win32'
// Threshold in characters for when to consider text a "large paste"export const PASTE_THRESHOLD = 800function getClipboardCommands() { const platform = process.platform as SupportedPlatform
// Platform-specific temporary file paths // Use CLAUDE_CODE_TMPDIR if set, otherwise fall back to platform defaults const baseTmpDir = process.env.CLAUDE_CODE_TMPDIR || (platform === 'win32' ? process.env.TEMP || 'C:\\Temp' : '/tmp') const screenshotFilename = 'claude_cli_latest_screenshot.png' const tempPaths: Record<SupportedPlatform, string> = { darwin: join(baseTmpDir, screenshotFilename), linux: join(baseTmpDir, screenshotFilename), win32: join(baseTmpDir, screenshotFilename), }
const screenshotPath = tempPaths[platform] || tempPaths.linux
// Platform-specific clipboard commands const commands: Record< SupportedPlatform, { checkImage: string saveImage: string getPath: string deleteFile: string } > = { darwin: { checkImage: `osascript -e 'the clipboard as «class PNGf»'`, saveImage: `osascript -e 'set png_data to (the clipboard as «class PNGf»)' -e 'set fp to open for access POSIX file "${screenshotPath}" with write permission' -e 'write png_data to fp' -e 'close access fp'`, getPath: `osascript -e 'get POSIX path of (the clipboard as «class furl»)'`, deleteFile: `rm -f "${screenshotPath}"`, }, linux: { checkImage: 'xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)" || wl-paste -l 2>/dev/null | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)"', saveImage: `xclip -selection clipboard -t image/png -o > "${screenshotPath}" 2>/dev/null || wl-paste --type image/png > "${screenshotPath}" 2>/dev/null || xclip -selection clipboard -t image/bmp -o > "${screenshotPath}" 2>/dev/null || wl-paste --type image/bmp > "${screenshotPath}"`, getPath: 'xclip -selection clipboard -t text/plain -o 2>/dev/null || wl-paste 2>/dev/null', deleteFile: `rm -f "${screenshotPath}"`, }, win32: { checkImage: 'powershell -NoProfile -Command "(Get-Clipboard -Format Image) -ne $null"', saveImage: `powershell -NoProfile -Command "$img = Get-Clipboard -Format Image; if ($img) { $img.Save('${screenshotPath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png) }"`, getPath: 'powershell -NoProfile -Command "Get-Clipboard"', deleteFile: `del /f "${screenshotPath}"`, }, }
return { commands: commands[platform] || commands.linux, screenshotPath, }}
export type ImageWithDimensions = { base64: string mediaType: string dimensions?: ImageDimensions}
/** * Check if clipboard contains an image without retrieving it. */export async function hasImageInClipboard(): Promise<boolean> { if (process.platform !== 'darwin') { return false } if ( feature('NATIVE_CLIPBOARD_IMAGE') && getFeatureValue_CACHED_MAY_BE_STALE('tengu_collage_kaleidoscope', true) ) { // Native NSPasteboard check (~0.03ms warm). Fall through to osascript // when the module/export is missing. Catch a throw too: it would surface // as an unhandled rejection in useClipboardImageHint's setTimeout. try { const { getNativeModule } = await import('image-processor-napi') const hasImage = getNativeModule()?.hasClipboardImage if (hasImage) { return hasImage() } } catch (e) { logError(e as Error) } } const result = await execFileNoThrowWithCwd('osascript', [ '-e', 'the clipboard as «class PNGf»', ]) return result.code === 0}
export async function getImageFromClipboard(): Promise<ImageWithDimensions | null> { // Fast path: native NSPasteboard reader (macOS only). Reads PNG bytes // directly in-process and downsamples via CoreGraphics if over the // dimension cap. ~5ms cold, sub-ms warm — vs. ~1.5s for the osascript // path below. Throws if the native module is unavailable, in which case // the catch block falls through to osascript. A `null` return from the // native call is authoritative (clipboard has no image). if ( feature('NATIVE_CLIPBOARD_IMAGE') && process.platform === 'darwin' && getFeatureValue_CACHED_MAY_BE_STALE('tengu_collage_kaleidoscope', true) ) { try { const { getNativeModule } = await import('image-processor-napi') const readClipboard = getNativeModule()?.readClipboardImage if (!readClipboard) { throw new Error('native clipboard reader unavailable') } const native = readClipboard(IMAGE_MAX_WIDTH, IMAGE_MAX_HEIGHT) if (!native) { return null } // The native path caps dimensions but not file size. A complex // 2000×2000 PNG can still exceed the 3.75MB raw / 5MB base64 API // limit — for that edge case, run through the same size-cap that // the osascript path uses (degrades to JPEG if needed). Cheap if // already under: just a sharp metadata read. const buffer: Buffer = native.png if (buffer.length > IMAGE_TARGET_RAW_SIZE) { const resized = await maybeResizeAndDownsampleImageBuffer( buffer, buffer.length, 'png', ) return { base64: resized.buffer.toString('base64'), mediaType: `image/${resized.mediaType}`, // resized.dimensions sees the already-downsampled buffer; native knows the true originals. dimensions: { originalWidth: native.originalWidth, originalHeight: native.originalHeight, displayWidth: resized.dimensions?.displayWidth ?? native.width, displayHeight: resized.dimensions?.displayHeight ?? native.height, }, } } return { base64: buffer.toString('base64'), mediaType: 'image/png', dimensions: { originalWidth: native.originalWidth, originalHeight: native.originalHeight, displayWidth: native.width, displayHeight: native.height, }, } } catch (e) { logError(e as Error) // Fall through to osascript fallback. } }
const { commands, screenshotPath } = getClipboardCommands() try { // Check if clipboard has image const checkResult = await execa(commands.checkImage, { shell: true, reject: false, }) if (checkResult.exitCode !== 0) { return null }
// Save the image const saveResult = await execa(commands.saveImage, { shell: true, reject: false, }) if (saveResult.exitCode !== 0) { return null }
// Read the image and convert to base64 let imageBuffer = getFsImplementation().readFileBytesSync(screenshotPath)
// BMP is not supported by the API — convert to PNG via Sharp. // This handles WSL2 where Windows copies images as BMP by default. if ( imageBuffer.length >= 2 && imageBuffer[0] === 0x42 && imageBuffer[1] === 0x4d ) { const sharp = await getImageProcessor() imageBuffer = await sharp(imageBuffer).png().toBuffer() }
// Resize if needed to stay under 5MB API limit const resized = await maybeResizeAndDownsampleImageBuffer( imageBuffer, imageBuffer.length, 'png', ) const base64Image = resized.buffer.toString('base64')
// Detect format from magic bytes const mediaType = detectImageFormatFromBase64(base64Image)
// Cleanup (fire-and-forget, don't await) void execa(commands.deleteFile, { shell: true, reject: false })
return { base64: base64Image, mediaType, dimensions: resized.dimensions, } } catch { return null }}
export async function getImagePathFromClipboard(): Promise<string | null> { const { commands } = getClipboardCommands()
try { // Try to get text from clipboard const result = await execa(commands.getPath, { shell: true, reject: false, }) if (result.exitCode !== 0 || !result.stdout) { return null } return result.stdout.trim() } catch (e) { logError(e as Error) return null }}
/** * Regex pattern to match supported image file extensions. Kept in sync with * MIME_BY_EXT in BriefTool/upload.ts — attachments.ts uses this to set isImage * on the wire, and remote viewers fetch /preview iff isImage is true. An ext * here but not in MIME_BY_EXT (e.g. bmp) uploads as octet-stream and has no * /preview variant → broken thumbnail. */export const IMAGE_EXTENSION_REGEX = /\.(png|jpe?g|gif|webp)$/i
/** * Remove outer single or double quotes from a string * @param text Text to clean * @returns Text without outer quotes */function removeOuterQuotes(text: string): string { if ( (text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")) ) { return text.slice(1, -1) } return text}
/** * Remove shell escape backslashes from a path (for macOS/Linux/WSL) * On Windows systems, this function returns the path unchanged * @param path Path that might contain shell-escaped characters * @returns Path with escape backslashes removed (on macOS/Linux/WSL only) */function stripBackslashEscapes(path: string): string { const platform = process.platform as SupportedPlatform
// On Windows, don't remove backslashes as they're part of the path if (platform === 'win32') { return path }
// On macOS/Linux/WSL, handle shell-escaped paths // Double-backslashes (\\) represent actual backslashes in the filename // Single backslashes followed by special chars are shell escapes
// First, temporarily replace double backslashes with a placeholder // Use random salt to prevent injection attacks where path contains literal placeholder const salt = randomBytes(8).toString('hex') const placeholder = `__DOUBLE_BACKSLASH_${salt}__` const withPlaceholder = path.replace(/\\\\/g, placeholder)
// Remove single backslashes that are shell escapes // This handles cases like "name\ \(15\).png" -> "name (15).png" const withoutEscapes = withPlaceholder.replace(/\\(.)/g, '$1')
// Replace placeholders back to single backslashes return withoutEscapes.replace(new RegExp(placeholder, 'g'), '\\')}
/** * Check if a given text represents an image file path * @param text Text to check * @returns Boolean indicating if text is an image path */export function isImageFilePath(text: string): boolean { const cleaned = removeOuterQuotes(text.trim()) const unescaped = stripBackslashEscapes(cleaned) return IMAGE_EXTENSION_REGEX.test(unescaped)}
/** * Clean and normalize a text string that might be an image file path * @param text Text to process * @returns Cleaned text with quotes removed, whitespace trimmed, and shell escapes removed, or null if not an image path */export function asImageFilePath(text: string): string | null { const cleaned = removeOuterQuotes(text.trim()) const unescaped = stripBackslashEscapes(cleaned)
if (IMAGE_EXTENSION_REGEX.test(unescaped)) { return unescaped }
return null}
/** * Try to find and read an image file, falling back to clipboard search * @param text Pasted text that might be an image filename or path * @returns Object containing the image path and base64 data, or null if not found */export async function tryReadImageFromPath( text: string,): Promise<(ImageWithDimensions & { path: string }) | null> { // Strip terminal added spaces or quotes to dragged in paths const cleanedPath = asImageFilePath(text)
if (!cleanedPath) { return null }
const imagePath = cleanedPath let imageBuffer
try { if (isAbsolute(imagePath)) { imageBuffer = getFsImplementation().readFileBytesSync(imagePath) } else { // VSCode Terminal just grabs the text content which is the filename // instead of getting the full path of the file pasted with cmd-v. So // we check if it matches the filename of the image in the clipboard. const clipboardPath = await getImagePathFromClipboard() if (clipboardPath && imagePath === basename(clipboardPath)) { imageBuffer = getFsImplementation().readFileBytesSync(clipboardPath) } } } catch (e) { logError(e as Error) return null } if (!imageBuffer) { return null } if (imageBuffer.length === 0) { logForDebugging(`Image file is empty: ${imagePath}`, { level: 'warn' }) return null }
// BMP is not supported by the API — convert to PNG via Sharp. if ( imageBuffer.length >= 2 && imageBuffer[0] === 0x42 && imageBuffer[1] === 0x4d ) { const sharp = await getImageProcessor() imageBuffer = await sharp(imageBuffer).png().toBuffer() }
// Resize if needed to stay under 5MB API limit // Extract extension from path for format hint const ext = extname(imagePath).slice(1).toLowerCase() || 'png' const resized = await maybeResizeAndDownsampleImageBuffer( imageBuffer, imageBuffer.length, ext, ) const base64Image = resized.buffer.toString('base64')
// Detect format from the actual file contents using magic bytes const mediaType = detectImageFormatFromBase64(base64Image) return { path: imagePath, base64: base64Image, mediaType, dimensions: resized.dimensions, }}