source dump of claude code
at main 236 lines 7.1 kB view raw
1import { readdir } from 'fs/promises' 2import { join } from 'path' 3import { coerce as semverCoerce } from 'semver' 4import { getSessionId } from '../bootstrap/state.js' 5import { getCwd } from './cwd.js' 6import { logForDebugging } from './debug.js' 7import { execFileNoThrow } from './execFileNoThrow.js' 8import { pathExists } from './file.js' 9import { gte as semverGte } from './semver.js' 10 11const MIN_DESKTOP_VERSION = '1.1.2396' 12 13function isDevMode(): boolean { 14 if ((process.env.NODE_ENV as string) === 'development') { 15 return true 16 } 17 18 // Local builds from build directories are dev mode even with NODE_ENV=production 19 const pathsToCheck = [process.argv[1] || '', process.execPath || ''] 20 const buildDirs = [ 21 '/build-ant/', 22 '/build-ant-native/', 23 '/build-external/', 24 '/build-external-native/', 25 ] 26 27 return pathsToCheck.some(p => buildDirs.some(dir => p.includes(dir))) 28} 29 30/** 31 * Builds a deep link URL for Claude Desktop to resume a CLI session. 32 * Format: claude://resume?session={sessionId}&cwd={cwd} 33 * In dev mode: claude-dev://resume?session={sessionId}&cwd={cwd} 34 */ 35function buildDesktopDeepLink(sessionId: string): string { 36 const protocol = isDevMode() ? 'claude-dev' : 'claude' 37 const url = new URL(`${protocol}://resume`) 38 url.searchParams.set('session', sessionId) 39 url.searchParams.set('cwd', getCwd()) 40 return url.toString() 41} 42 43/** 44 * Check if Claude Desktop app is installed. 45 * On macOS, checks for /Applications/Claude.app. 46 * On Linux, checks if xdg-open can handle claude:// protocol. 47 * On Windows, checks if the protocol handler exists. 48 * In dev mode, always returns true (assumes dev Desktop is running). 49 */ 50async function isDesktopInstalled(): Promise<boolean> { 51 // In dev mode, assume the dev Desktop app is running 52 if (isDevMode()) { 53 return true 54 } 55 56 const platform = process.platform 57 58 if (platform === 'darwin') { 59 // Check for Claude.app in /Applications 60 return pathExists('/Applications/Claude.app') 61 } else if (platform === 'linux') { 62 // Check if xdg-mime can find a handler for claude:// 63 // Note: xdg-mime returns exit code 0 even with no handler, so check stdout too 64 const { code, stdout } = await execFileNoThrow('xdg-mime', [ 65 'query', 66 'default', 67 'x-scheme-handler/claude', 68 ]) 69 return code === 0 && stdout.trim().length > 0 70 } else if (platform === 'win32') { 71 // On Windows, try to query the registry for the protocol handler 72 const { code } = await execFileNoThrow('reg', [ 73 'query', 74 'HKEY_CLASSES_ROOT\\claude', 75 '/ve', 76 ]) 77 return code === 0 78 } 79 80 return false 81} 82 83/** 84 * Detect the installed Claude Desktop version. 85 * On macOS, reads CFBundleShortVersionString from the app plist. 86 * On Windows, finds the highest app-X.Y.Z directory in the Squirrel install. 87 * Returns null if version cannot be determined. 88 */ 89async function getDesktopVersion(): Promise<string | null> { 90 const platform = process.platform 91 92 if (platform === 'darwin') { 93 const { code, stdout } = await execFileNoThrow('defaults', [ 94 'read', 95 '/Applications/Claude.app/Contents/Info.plist', 96 'CFBundleShortVersionString', 97 ]) 98 if (code !== 0) { 99 return null 100 } 101 const version = stdout.trim() 102 return version.length > 0 ? version : null 103 } else if (platform === 'win32') { 104 const localAppData = process.env.LOCALAPPDATA 105 if (!localAppData) { 106 return null 107 } 108 const installDir = join(localAppData, 'AnthropicClaude') 109 try { 110 const entries = await readdir(installDir) 111 const versions = entries 112 .filter(e => e.startsWith('app-')) 113 .map(e => e.slice(4)) 114 .filter(v => semverCoerce(v) !== null) 115 .sort((a, b) => { 116 const ca = semverCoerce(a)! 117 const cb = semverCoerce(b)! 118 return ca.compare(cb) 119 }) 120 return versions.length > 0 ? versions[versions.length - 1]! : null 121 } catch { 122 return null 123 } 124 } 125 126 return null 127} 128 129export type DesktopInstallStatus = 130 | { status: 'not-installed' } 131 | { status: 'version-too-old'; version: string } 132 | { status: 'ready'; version: string } 133 134/** 135 * Check Desktop install status including version compatibility. 136 */ 137export async function getDesktopInstallStatus(): Promise<DesktopInstallStatus> { 138 const installed = await isDesktopInstalled() 139 if (!installed) { 140 return { status: 'not-installed' } 141 } 142 143 let version: string | null 144 try { 145 version = await getDesktopVersion() 146 } catch { 147 // Best effort — proceed with handoff if version detection fails 148 return { status: 'ready', version: 'unknown' } 149 } 150 151 if (!version) { 152 // Can't determine version — assume it's ready (dev mode or unknown install) 153 return { status: 'ready', version: 'unknown' } 154 } 155 156 const coerced = semverCoerce(version) 157 if (!coerced || !semverGte(coerced.version, MIN_DESKTOP_VERSION)) { 158 return { status: 'version-too-old', version } 159 } 160 161 return { status: 'ready', version } 162} 163 164/** 165 * Opens a deep link URL using the platform-specific mechanism. 166 * Returns true if the command succeeded, false otherwise. 167 */ 168async function openDeepLink(deepLinkUrl: string): Promise<boolean> { 169 const platform = process.platform 170 logForDebugging(`Opening deep link: ${deepLinkUrl}`) 171 172 if (platform === 'darwin') { 173 if (isDevMode()) { 174 // In dev mode, `open` launches a bare Electron binary (without app code) 175 // because setAsDefaultProtocolClient registers just the Electron executable. 176 // Use AppleScript to route the URL to the already-running Electron app. 177 const { code } = await execFileNoThrow('osascript', [ 178 '-e', 179 `tell application "Electron" to open location "${deepLinkUrl}"`, 180 ]) 181 return code === 0 182 } 183 const { code } = await execFileNoThrow('open', [deepLinkUrl]) 184 return code === 0 185 } else if (platform === 'linux') { 186 const { code } = await execFileNoThrow('xdg-open', [deepLinkUrl]) 187 return code === 0 188 } else if (platform === 'win32') { 189 // On Windows, use cmd /c start to open URLs 190 const { code } = await execFileNoThrow('cmd', [ 191 '/c', 192 'start', 193 '', 194 deepLinkUrl, 195 ]) 196 return code === 0 197 } 198 199 return false 200} 201 202/** 203 * Build and open a deep link to resume the current session in Claude Desktop. 204 * Returns an object with success status and any error message. 205 */ 206export async function openCurrentSessionInDesktop(): Promise<{ 207 success: boolean 208 error?: string 209 deepLinkUrl?: string 210}> { 211 const sessionId = getSessionId() 212 213 // Check if Desktop is installed 214 const installed = await isDesktopInstalled() 215 if (!installed) { 216 return { 217 success: false, 218 error: 219 'Claude Desktop is not installed. Install it from https://claude.ai/download', 220 } 221 } 222 223 // Build and open the deep link 224 const deepLinkUrl = buildDesktopDeepLink(sessionId) 225 const opened = await openDeepLink(deepLinkUrl) 226 227 if (!opened) { 228 return { 229 success: false, 230 error: 'Failed to open Claude Desktop. Please try opening it manually.', 231 deepLinkUrl, 232 } 233 } 234 235 return { success: true, deepLinkUrl } 236}