source dump of claude code
at main 557 lines 18 kB view raw
1/** 2 * Terminal Launcher 3 * 4 * Detects the user's preferred terminal emulator and launches Claude Code 5 * inside it. Used by the deep link protocol handler when invoked by the OS 6 * (i.e., not already running inside a terminal). 7 * 8 * Platform support: 9 * macOS — Terminal.app, iTerm2, Ghostty, Kitty, Alacritty, WezTerm 10 * Linux — $TERMINAL, x-terminal-emulator, gnome-terminal, konsole, etc. 11 * Windows — Windows Terminal (wt.exe), PowerShell, cmd.exe 12 */ 13 14import { spawn } from 'child_process' 15import { basename } from 'path' 16import { getGlobalConfig } from '../config.js' 17import { logForDebugging } from '../debug.js' 18import { execFileNoThrow } from '../execFileNoThrow.js' 19import { which } from '../which.js' 20 21export type TerminalInfo = { 22 name: string 23 command: string 24} 25 26// macOS terminals in preference order. 27// Each entry: [display name, app bundle name or CLI command, detection method] 28const MACOS_TERMINALS: Array<{ 29 name: string 30 bundleId: string 31 app: string 32}> = [ 33 { name: 'iTerm2', bundleId: 'com.googlecode.iterm2', app: 'iTerm' }, 34 { name: 'Ghostty', bundleId: 'com.mitchellh.ghostty', app: 'Ghostty' }, 35 { name: 'Kitty', bundleId: 'net.kovidgoyal.kitty', app: 'kitty' }, 36 { name: 'Alacritty', bundleId: 'org.alacritty', app: 'Alacritty' }, 37 { name: 'WezTerm', bundleId: 'com.github.wez.wezterm', app: 'WezTerm' }, 38 { 39 name: 'Terminal.app', 40 bundleId: 'com.apple.Terminal', 41 app: 'Terminal', 42 }, 43] 44 45// Linux terminals in preference order (command name) 46const LINUX_TERMINALS = [ 47 'ghostty', 48 'kitty', 49 'alacritty', 50 'wezterm', 51 'gnome-terminal', 52 'konsole', 53 'xfce4-terminal', 54 'mate-terminal', 55 'tilix', 56 'xterm', 57] 58 59/** 60 * Detect the user's preferred terminal on macOS. 61 * Checks running processes first (most likely to be what the user prefers), 62 * then falls back to checking installed .app bundles. 63 */ 64async function detectMacosTerminal(): Promise<TerminalInfo> { 65 // Stored preference from a previous interactive session. This is the only 66 // signal that survives into the headless LaunchServices context — the env 67 // var check below never hits when we're launched from a browser link. 68 const stored = getGlobalConfig().deepLinkTerminal 69 if (stored) { 70 const match = MACOS_TERMINALS.find(t => t.app === stored) 71 if (match) { 72 return { name: match.name, command: match.app } 73 } 74 } 75 76 // Check the TERM_PROGRAM env var — if set, the user has a clear preference. 77 // TERM_PROGRAM may include a .app suffix (e.g., "iTerm.app"), so strip it. 78 const termProgram = process.env.TERM_PROGRAM 79 if (termProgram) { 80 const normalized = termProgram.replace(/\.app$/i, '').toLowerCase() 81 const match = MACOS_TERMINALS.find( 82 t => 83 t.app.toLowerCase() === normalized || 84 t.name.toLowerCase() === normalized, 85 ) 86 if (match) { 87 return { name: match.name, command: match.app } 88 } 89 } 90 91 // Check which terminals are installed by looking for .app bundles. 92 // Try mdfind first (Spotlight), but fall back to checking /Applications 93 // directly since mdfind can return empty results if Spotlight is disabled 94 // or hasn't indexed the app yet. 95 for (const terminal of MACOS_TERMINALS) { 96 const { code, stdout } = await execFileNoThrow( 97 'mdfind', 98 [`kMDItemCFBundleIdentifier == "${terminal.bundleId}"`], 99 { timeout: 5000, useCwd: false }, 100 ) 101 if (code === 0 && stdout.trim().length > 0) { 102 return { name: terminal.name, command: terminal.app } 103 } 104 } 105 106 // Fallback: check /Applications directly (mdfind may not work if 107 // Spotlight indexing is disabled or incomplete) 108 for (const terminal of MACOS_TERMINALS) { 109 const { code: lsCode } = await execFileNoThrow( 110 'ls', 111 [`/Applications/${terminal.app}.app`], 112 { timeout: 1000, useCwd: false }, 113 ) 114 if (lsCode === 0) { 115 return { name: terminal.name, command: terminal.app } 116 } 117 } 118 119 // Terminal.app is always available on macOS 120 return { name: 'Terminal.app', command: 'Terminal' } 121} 122 123/** 124 * Detect the user's preferred terminal on Linux. 125 * Checks $TERMINAL, then x-terminal-emulator, then walks a priority list. 126 */ 127async function detectLinuxTerminal(): Promise<TerminalInfo | null> { 128 // Check $TERMINAL env var 129 const termEnv = process.env.TERMINAL 130 if (termEnv) { 131 const resolved = await which(termEnv) 132 if (resolved) { 133 return { name: basename(termEnv), command: resolved } 134 } 135 } 136 137 // Check x-terminal-emulator (Debian/Ubuntu alternative) 138 const xte = await which('x-terminal-emulator') 139 if (xte) { 140 return { name: 'x-terminal-emulator', command: xte } 141 } 142 143 // Walk the priority list 144 for (const terminal of LINUX_TERMINALS) { 145 const resolved = await which(terminal) 146 if (resolved) { 147 return { name: terminal, command: resolved } 148 } 149 } 150 151 return null 152} 153 154/** 155 * Detect the user's preferred terminal on Windows. 156 */ 157async function detectWindowsTerminal(): Promise<TerminalInfo> { 158 // Check for Windows Terminal first 159 const wt = await which('wt.exe') 160 if (wt) { 161 return { name: 'Windows Terminal', command: wt } 162 } 163 164 // PowerShell 7+ (separate install) 165 const pwsh = await which('pwsh.exe') 166 if (pwsh) { 167 return { name: 'PowerShell', command: pwsh } 168 } 169 170 // Windows PowerShell 5.1 (built into Windows) 171 const powershell = await which('powershell.exe') 172 if (powershell) { 173 return { name: 'PowerShell', command: powershell } 174 } 175 176 // cmd.exe is always available 177 return { name: 'Command Prompt', command: 'cmd.exe' } 178} 179 180/** 181 * Detect the user's preferred terminal emulator. 182 */ 183export async function detectTerminal(): Promise<TerminalInfo | null> { 184 switch (process.platform) { 185 case 'darwin': 186 return detectMacosTerminal() 187 case 'linux': 188 return detectLinuxTerminal() 189 case 'win32': 190 return detectWindowsTerminal() 191 default: 192 return null 193 } 194} 195 196/** 197 * Launch Claude Code in the detected terminal emulator. 198 * 199 * Pure argv paths (no shell, user input never touches an interpreter): 200 * macOS — Ghostty, Alacritty, Kitty, WezTerm (via open -na --args) 201 * Linux — all ten in LINUX_TERMINALS 202 * Windows — Windows Terminal 203 * 204 * Shell-string paths (user input is shell-quoted and relied upon): 205 * macOS — iTerm2, Terminal.app (AppleScript `write text` / `do script` 206 * are inherently shell-interpreted; no argv interface exists) 207 * Windows — PowerShell -Command, cmd.exe /k (no argv exec mode) 208 * 209 * For pure-argv paths: claudePath, --prefill, query, cwd travel as distinct 210 * argv elements end-to-end. No sh -c. No shellQuote(). The terminal does 211 * chdir(cwd) and execvp(claude, argv). Spaces/quotes/metacharacters in 212 * query or cwd are preserved by argv boundaries with zero interpretation. 213 */ 214export async function launchInTerminal( 215 claudePath: string, 216 action: { 217 query?: string 218 cwd?: string 219 repo?: string 220 lastFetchMs?: number 221 }, 222): Promise<boolean> { 223 const terminal = await detectTerminal() 224 if (!terminal) { 225 logForDebugging('No terminal emulator detected', { level: 'error' }) 226 return false 227 } 228 229 logForDebugging( 230 `Launching in terminal: ${terminal.name} (${terminal.command})`, 231 ) 232 const claudeArgs = ['--deep-link-origin'] 233 if (action.repo) { 234 claudeArgs.push('--deep-link-repo', action.repo) 235 if (action.lastFetchMs !== undefined) { 236 claudeArgs.push('--deep-link-last-fetch', String(action.lastFetchMs)) 237 } 238 } 239 if (action.query) { 240 claudeArgs.push('--prefill', action.query) 241 } 242 243 switch (process.platform) { 244 case 'darwin': 245 return launchMacosTerminal(terminal, claudePath, claudeArgs, action.cwd) 246 case 'linux': 247 return launchLinuxTerminal(terminal, claudePath, claudeArgs, action.cwd) 248 case 'win32': 249 return launchWindowsTerminal(terminal, claudePath, claudeArgs, action.cwd) 250 default: 251 return false 252 } 253} 254 255async function launchMacosTerminal( 256 terminal: TerminalInfo, 257 claudePath: string, 258 claudeArgs: string[], 259 cwd?: string, 260): Promise<boolean> { 261 switch (terminal.command) { 262 // --- SHELL-STRING PATHS (AppleScript has no argv interface) --- 263 // User input is shell-quoted via shellQuote(). These two are the only 264 // macOS paths where shellQuote() correctness is load-bearing. 265 266 case 'iTerm': { 267 const shCmd = buildShellCommand(claudePath, claudeArgs, cwd) 268 // If iTerm isn't running, `tell application` launches it and iTerm's 269 // default startup behavior opens a window — so `create window` would 270 // make a second one. Check `running` first: if already running (even 271 // with zero windows), create a window; if not, `activate` lets iTerm's 272 // startup create the first window. 273 const script = `tell application "iTerm" 274 if running then 275 create window with default profile 276 else 277 activate 278 end if 279 tell current session of current window 280 write text ${appleScriptQuote(shCmd)} 281 end tell 282end tell` 283 const { code } = await execFileNoThrow('osascript', ['-e', script], { 284 useCwd: false, 285 }) 286 if (code === 0) return true 287 break 288 } 289 290 case 'Terminal': { 291 const shCmd = buildShellCommand(claudePath, claudeArgs, cwd) 292 const script = `tell application "Terminal" 293 do script ${appleScriptQuote(shCmd)} 294 activate 295end tell` 296 const { code } = await execFileNoThrow('osascript', ['-e', script], { 297 useCwd: false, 298 }) 299 return code === 0 300 } 301 302 // --- PURE ARGV PATHS (no shell, no shellQuote) --- 303 // open -na <App> --args <argv> → app receives argv verbatim → 304 // terminal's native --working-directory + -e exec the command directly. 305 306 case 'Ghostty': { 307 const args = [ 308 '-na', 309 terminal.command, 310 '--args', 311 '--window-save-state=never', 312 ] 313 if (cwd) args.push(`--working-directory=${cwd}`) 314 args.push('-e', claudePath, ...claudeArgs) 315 const { code } = await execFileNoThrow('open', args, { useCwd: false }) 316 if (code === 0) return true 317 break 318 } 319 320 case 'Alacritty': { 321 const args = ['-na', terminal.command, '--args'] 322 if (cwd) args.push('--working-directory', cwd) 323 args.push('-e', claudePath, ...claudeArgs) 324 const { code } = await execFileNoThrow('open', args, { useCwd: false }) 325 if (code === 0) return true 326 break 327 } 328 329 case 'kitty': { 330 const args = ['-na', terminal.command, '--args'] 331 if (cwd) args.push('--directory', cwd) 332 args.push(claudePath, ...claudeArgs) 333 const { code } = await execFileNoThrow('open', args, { useCwd: false }) 334 if (code === 0) return true 335 break 336 } 337 338 case 'WezTerm': { 339 const args = ['-na', terminal.command, '--args', 'start'] 340 if (cwd) args.push('--cwd', cwd) 341 args.push('--', claudePath, ...claudeArgs) 342 const { code } = await execFileNoThrow('open', args, { useCwd: false }) 343 if (code === 0) return true 344 break 345 } 346 } 347 348 logForDebugging( 349 `Failed to launch ${terminal.name}, falling back to Terminal.app`, 350 ) 351 return launchMacosTerminal( 352 { name: 'Terminal.app', command: 'Terminal' }, 353 claudePath, 354 claudeArgs, 355 cwd, 356 ) 357} 358 359async function launchLinuxTerminal( 360 terminal: TerminalInfo, 361 claudePath: string, 362 claudeArgs: string[], 363 cwd?: string, 364): Promise<boolean> { 365 // All Linux paths are pure argv. Each terminal's --working-directory 366 // (or equivalent) sets cwd natively; the command is exec'd directly. 367 // For the few terminals without a cwd flag (xterm, and the opaque 368 // x-terminal-emulator / $TERMINAL), spawn({cwd}) sets the terminal 369 // process's cwd — most inherit it for the child. 370 371 let args: string[] 372 let spawnCwd: string | undefined 373 374 switch (terminal.name) { 375 case 'gnome-terminal': 376 args = cwd ? [`--working-directory=${cwd}`, '--'] : ['--'] 377 args.push(claudePath, ...claudeArgs) 378 break 379 case 'konsole': 380 args = cwd ? ['--workdir', cwd, '-e'] : ['-e'] 381 args.push(claudePath, ...claudeArgs) 382 break 383 case 'kitty': 384 args = cwd ? ['--directory', cwd] : [] 385 args.push(claudePath, ...claudeArgs) 386 break 387 case 'wezterm': 388 args = cwd ? ['start', '--cwd', cwd, '--'] : ['start', '--'] 389 args.push(claudePath, ...claudeArgs) 390 break 391 case 'alacritty': 392 args = cwd ? ['--working-directory', cwd, '-e'] : ['-e'] 393 args.push(claudePath, ...claudeArgs) 394 break 395 case 'ghostty': 396 args = cwd ? [`--working-directory=${cwd}`, '-e'] : ['-e'] 397 args.push(claudePath, ...claudeArgs) 398 break 399 case 'xfce4-terminal': 400 case 'mate-terminal': 401 args = cwd ? [`--working-directory=${cwd}`, '-x'] : ['-x'] 402 args.push(claudePath, ...claudeArgs) 403 break 404 case 'tilix': 405 args = cwd ? [`--working-directory=${cwd}`, '-e'] : ['-e'] 406 args.push(claudePath, ...claudeArgs) 407 break 408 default: 409 // xterm, x-terminal-emulator, $TERMINAL — no reliable cwd flag. 410 // spawn({cwd}) sets the terminal's own cwd; most inherit. 411 args = ['-e', claudePath, ...claudeArgs] 412 spawnCwd = cwd 413 break 414 } 415 416 return spawnDetached(terminal.command, args, { cwd: spawnCwd }) 417} 418 419async function launchWindowsTerminal( 420 terminal: TerminalInfo, 421 claudePath: string, 422 claudeArgs: string[], 423 cwd?: string, 424): Promise<boolean> { 425 const args: string[] = [] 426 427 switch (terminal.name) { 428 // --- PURE ARGV PATH --- 429 case 'Windows Terminal': 430 if (cwd) args.push('-d', cwd) 431 args.push('--', claudePath, ...claudeArgs) 432 break 433 434 // --- SHELL-STRING PATHS --- 435 // PowerShell -Command and cmd /k take a command string. No argv exec 436 // mode that also keeps the session interactive after claude exits. 437 // User input is escaped per-shell; correctness of that escaping is 438 // load-bearing here. 439 440 case 'PowerShell': { 441 // Single-quoted PowerShell strings have NO escape sequences (only 442 // '' for a literal quote). Double-quoted strings interpret backtick 443 // escapes — a query containing `" could break out. 444 const cdCmd = cwd ? `Set-Location ${psQuote(cwd)}; ` : '' 445 args.push( 446 '-NoExit', 447 '-Command', 448 `${cdCmd}& ${psQuote(claudePath)} ${claudeArgs.map(psQuote).join(' ')}`, 449 ) 450 break 451 } 452 453 default: { 454 const cdCmd = cwd ? `cd /d ${cmdQuote(cwd)} && ` : '' 455 args.push( 456 '/k', 457 `${cdCmd}${cmdQuote(claudePath)} ${claudeArgs.map(a => cmdQuote(a)).join(' ')}`, 458 ) 459 break 460 } 461 } 462 463 // cmd.exe does NOT use MSVCRT-style argument parsing. libuv's default 464 // quoting for spawn() on Windows assumes MSVCRT rules and would double- 465 // escape our already-cmdQuote'd string. Bypass it for cmd.exe only. 466 return spawnDetached(terminal.command, args, { 467 windowsVerbatimArguments: terminal.name === 'Command Prompt', 468 }) 469} 470 471/** 472 * Spawn a terminal detached so the handler process can exit without 473 * waiting for the terminal to close. Resolves false on spawn failure 474 * (ENOENT, EACCES) rather than crashing. 475 */ 476function spawnDetached( 477 command: string, 478 args: string[], 479 opts: { cwd?: string; windowsVerbatimArguments?: boolean } = {}, 480): Promise<boolean> { 481 return new Promise<boolean>(resolve => { 482 const child = spawn(command, args, { 483 detached: true, 484 stdio: 'ignore', 485 cwd: opts.cwd, 486 windowsVerbatimArguments: opts.windowsVerbatimArguments, 487 }) 488 child.once('error', err => { 489 logForDebugging(`Failed to spawn ${command}: ${err.message}`, { 490 level: 'error', 491 }) 492 void resolve(false) 493 }) 494 child.once('spawn', () => { 495 child.unref() 496 void resolve(true) 497 }) 498 }) 499} 500 501/** 502 * Build a single-quoted POSIX shell command string. ONLY used by the 503 * AppleScript paths (iTerm, Terminal.app) which have no argv interface. 504 */ 505function buildShellCommand( 506 claudePath: string, 507 claudeArgs: string[], 508 cwd?: string, 509): string { 510 const cdPrefix = cwd ? `cd ${shellQuote(cwd)} && ` : '' 511 return `${cdPrefix}${[claudePath, ...claudeArgs].map(shellQuote).join(' ')}` 512} 513 514/** 515 * POSIX single-quote escaping. Single-quoted strings have zero 516 * interpretation except for the closing single quote itself. 517 * Only used by buildShellCommand() for the AppleScript paths. 518 */ 519function shellQuote(s: string): string { 520 return `'${s.replace(/'/g, "'\\''")}'` 521} 522 523/** 524 * AppleScript string literal escaping (backslash then double-quote). 525 */ 526function appleScriptQuote(s: string): string { 527 return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` 528} 529 530/** 531 * PowerShell single-quoted string. The ONLY special sequence is '' for a 532 * literal single quote — no backtick escapes, no variable expansion, no 533 * subexpressions. This is the safe PowerShell quoting; double-quoted 534 * strings interpret `n `t `" etc. and can be escaped out of. 535 */ 536function psQuote(s: string): string { 537 return `'${s.replace(/'/g, "''")}'` 538} 539 540/** 541 * cmd.exe argument quoting. cmd.exe does NOT use CommandLineToArgvW-style 542 * backslash escaping — it toggles its quoting state on every raw " 543 * character, so an embedded " breaks out of the quoted region and exposes 544 * metacharacters (& | < > ^) to cmd.exe interpretation = command injection. 545 * 546 * Strategy: strip " from the input (it cannot be safely represented in a 547 * cmd.exe double-quoted string). Escape % as %% to prevent environment 548 * variable expansion (%PATH% etc.) which cmd.exe performs even inside 549 * double quotes. Trailing backslashes are still doubled because the 550 * *child process* (claude.exe) uses CommandLineToArgvW, where a trailing 551 * \ before our closing " would eat the close-quote. 552 */ 553function cmdQuote(arg: string): string { 554 const stripped = arg.replace(/"/g, '').replace(/%/g, '%%') 555 const escaped = stripped.replace(/(\\+)$/, '$1$1') 556 return `"${escaped}"` 557}