/ utils / deepLink / terminalLauncher.ts
terminalLauncher.ts
  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  
 14  import { spawn } from 'child_process'
 15  import { basename } from 'path'
 16  import { getGlobalConfig } from '../config.js'
 17  import { logForDebugging } from '../debug.js'
 18  import { execFileNoThrow } from '../execFileNoThrow.js'
 19  import { which } from '../which.js'
 20  
 21  export 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]
 28  const 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)
 46  const 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   */
 64  async 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   */
127  async 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   */
157  async 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   */
183  export 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   */
214  export 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  
255  async 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
282  end 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
295  end 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  
359  async 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  
419  async 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   */
476  function 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   */
505  function 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   */
519  function shellQuote(s: string): string {
520    return `'${s.replace(/'/g, "'\\''")}'`
521  }
522  
523  /**
524   * AppleScript string literal escaping (backslash then double-quote).
525   */
526  function 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   */
536  function 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   */
553  function cmdQuote(arg: string): string {
554    const stripped = arg.replace(/"/g, '').replace(/%/g, '%%')
555    const escaped = stripped.replace(/(\\+)$/, '$1$1')
556    return `"${escaped}"`
557  }