import { readdir } from 'fs/promises' import { join } from 'path' import { coerce as semverCoerce } from 'semver' import { getSessionId } from '../bootstrap/state.js' import { getCwd } from './cwd.js' import { logForDebugging } from './debug.js' import { execFileNoThrow } from './execFileNoThrow.js' import { pathExists } from './file.js' import { gte as semverGte } from './semver.js' const MIN_DESKTOP_VERSION = '1.1.2396' function isDevMode(): boolean { if ((process.env.NODE_ENV as string) === 'development') { return true } // Local builds from build directories are dev mode even with NODE_ENV=production const pathsToCheck = [process.argv[1] || '', process.execPath || ''] const buildDirs = [ '/build-ant/', '/build-ant-native/', '/build-external/', '/build-external-native/', ] return pathsToCheck.some(p => buildDirs.some(dir => p.includes(dir))) } /** * Builds a deep link URL for Claude Desktop to resume a CLI session. * Format: claude://resume?session={sessionId}&cwd={cwd} * In dev mode: claude-dev://resume?session={sessionId}&cwd={cwd} */ function buildDesktopDeepLink(sessionId: string): string { const protocol = isDevMode() ? 'claude-dev' : 'claude' const url = new URL(`${protocol}://resume`) url.searchParams.set('session', sessionId) url.searchParams.set('cwd', getCwd()) return url.toString() } /** * Check if Claude Desktop app is installed. * On macOS, checks for /Applications/Claude.app. * On Linux, checks if xdg-open can handle claude:// protocol. * On Windows, checks if the protocol handler exists. * In dev mode, always returns true (assumes dev Desktop is running). */ async function isDesktopInstalled(): Promise { // In dev mode, assume the dev Desktop app is running if (isDevMode()) { return true } const platform = process.platform if (platform === 'darwin') { // Check for Claude.app in /Applications return pathExists('/Applications/Claude.app') } else if (platform === 'linux') { // Check if xdg-mime can find a handler for claude:// // Note: xdg-mime returns exit code 0 even with no handler, so check stdout too const { code, stdout } = await execFileNoThrow('xdg-mime', [ 'query', 'default', 'x-scheme-handler/claude', ]) return code === 0 && stdout.trim().length > 0 } else if (platform === 'win32') { // On Windows, try to query the registry for the protocol handler const { code } = await execFileNoThrow('reg', [ 'query', 'HKEY_CLASSES_ROOT\\claude', '/ve', ]) return code === 0 } return false } /** * Detect the installed Claude Desktop version. * On macOS, reads CFBundleShortVersionString from the app plist. * On Windows, finds the highest app-X.Y.Z directory in the Squirrel install. * Returns null if version cannot be determined. */ async function getDesktopVersion(): Promise { const platform = process.platform if (platform === 'darwin') { const { code, stdout } = await execFileNoThrow('defaults', [ 'read', '/Applications/Claude.app/Contents/Info.plist', 'CFBundleShortVersionString', ]) if (code !== 0) { return null } const version = stdout.trim() return version.length > 0 ? version : null } else if (platform === 'win32') { const localAppData = process.env.LOCALAPPDATA if (!localAppData) { return null } const installDir = join(localAppData, 'AnthropicClaude') try { const entries = await readdir(installDir) const versions = entries .filter(e => e.startsWith('app-')) .map(e => e.slice(4)) .filter(v => semverCoerce(v) !== null) .sort((a, b) => { const ca = semverCoerce(a)! const cb = semverCoerce(b)! return ca.compare(cb) }) return versions.length > 0 ? versions[versions.length - 1]! : null } catch { return null } } return null } export type DesktopInstallStatus = | { status: 'not-installed' } | { status: 'version-too-old'; version: string } | { status: 'ready'; version: string } /** * Check Desktop install status including version compatibility. */ export async function getDesktopInstallStatus(): Promise { const installed = await isDesktopInstalled() if (!installed) { return { status: 'not-installed' } } let version: string | null try { version = await getDesktopVersion() } catch { // Best effort — proceed with handoff if version detection fails return { status: 'ready', version: 'unknown' } } if (!version) { // Can't determine version — assume it's ready (dev mode or unknown install) return { status: 'ready', version: 'unknown' } } const coerced = semverCoerce(version) if (!coerced || !semverGte(coerced.version, MIN_DESKTOP_VERSION)) { return { status: 'version-too-old', version } } return { status: 'ready', version } } /** * Opens a deep link URL using the platform-specific mechanism. * Returns true if the command succeeded, false otherwise. */ async function openDeepLink(deepLinkUrl: string): Promise { const platform = process.platform logForDebugging(`Opening deep link: ${deepLinkUrl}`) if (platform === 'darwin') { if (isDevMode()) { // In dev mode, `open` launches a bare Electron binary (without app code) // because setAsDefaultProtocolClient registers just the Electron executable. // Use AppleScript to route the URL to the already-running Electron app. const { code } = await execFileNoThrow('osascript', [ '-e', `tell application "Electron" to open location "${deepLinkUrl}"`, ]) return code === 0 } const { code } = await execFileNoThrow('open', [deepLinkUrl]) return code === 0 } else if (platform === 'linux') { const { code } = await execFileNoThrow('xdg-open', [deepLinkUrl]) return code === 0 } else if (platform === 'win32') { // On Windows, use cmd /c start to open URLs const { code } = await execFileNoThrow('cmd', [ '/c', 'start', '', deepLinkUrl, ]) return code === 0 } return false } /** * Build and open a deep link to resume the current session in Claude Desktop. * Returns an object with success status and any error message. */ export async function openCurrentSessionInDesktop(): Promise<{ success: boolean error?: string deepLinkUrl?: string }> { const sessionId = getSessionId() // Check if Desktop is installed const installed = await isDesktopInstalled() if (!installed) { return { success: false, error: 'Claude Desktop is not installed. Install it from https://claude.ai/download', } } // Build and open the deep link const deepLinkUrl = buildDesktopDeepLink(sessionId) const opened = await openDeepLink(deepLinkUrl) if (!opened) { return { success: false, error: 'Failed to open Claude Desktop. Please try opening it manually.', deepLinkUrl, } } return { success: true, deepLinkUrl } }