558 lines
17 KiB
TypeScript
558 lines
17 KiB
TypeScript
/**
|
|
* Terminal Launcher
|
|
*
|
|
* Detects the user's preferred terminal emulator and launches Claude Code
|
|
* inside it. Used by the deep link protocol handler when invoked by the OS
|
|
* (i.e., not already running inside a terminal).
|
|
*
|
|
* Platform support:
|
|
* macOS — Terminal.app, iTerm2, Ghostty, Kitty, Alacritty, WezTerm
|
|
* Linux — $TERMINAL, x-terminal-emulator, gnome-terminal, konsole, etc.
|
|
* Windows — Windows Terminal (wt.exe), PowerShell, cmd.exe
|
|
*/
|
|
|
|
import { spawn } from 'child_process'
|
|
import { basename } from 'path'
|
|
import { getGlobalConfig } from '../config.js'
|
|
import { logForDebugging } from '../debug.js'
|
|
import { execFileNoThrow } from '../execFileNoThrow.js'
|
|
import { which } from '../which.js'
|
|
|
|
export type TerminalInfo = {
|
|
name: string
|
|
command: string
|
|
}
|
|
|
|
// macOS terminals in preference order.
|
|
// Each entry: [display name, app bundle name or CLI command, detection method]
|
|
const MACOS_TERMINALS: Array<{
|
|
name: string
|
|
bundleId: string
|
|
app: string
|
|
}> = [
|
|
{ name: 'iTerm2', bundleId: 'com.googlecode.iterm2', app: 'iTerm' },
|
|
{ name: 'Ghostty', bundleId: 'com.mitchellh.ghostty', app: 'Ghostty' },
|
|
{ name: 'Kitty', bundleId: 'net.kovidgoyal.kitty', app: 'kitty' },
|
|
{ name: 'Alacritty', bundleId: 'org.alacritty', app: 'Alacritty' },
|
|
{ name: 'WezTerm', bundleId: 'com.github.wez.wezterm', app: 'WezTerm' },
|
|
{
|
|
name: 'Terminal.app',
|
|
bundleId: 'com.apple.Terminal',
|
|
app: 'Terminal',
|
|
},
|
|
]
|
|
|
|
// Linux terminals in preference order (command name)
|
|
const LINUX_TERMINALS = [
|
|
'ghostty',
|
|
'kitty',
|
|
'alacritty',
|
|
'wezterm',
|
|
'gnome-terminal',
|
|
'konsole',
|
|
'xfce4-terminal',
|
|
'mate-terminal',
|
|
'tilix',
|
|
'xterm',
|
|
]
|
|
|
|
/**
|
|
* Detect the user's preferred terminal on macOS.
|
|
* Checks running processes first (most likely to be what the user prefers),
|
|
* then falls back to checking installed .app bundles.
|
|
*/
|
|
async function detectMacosTerminal(): Promise<TerminalInfo> {
|
|
// Stored preference from a previous interactive session. This is the only
|
|
// signal that survives into the headless LaunchServices context — the env
|
|
// var check below never hits when we're launched from a browser link.
|
|
const stored = getGlobalConfig().deepLinkTerminal
|
|
if (stored) {
|
|
const match = MACOS_TERMINALS.find(t => t.app === stored)
|
|
if (match) {
|
|
return { name: match.name, command: match.app }
|
|
}
|
|
}
|
|
|
|
// Check the TERM_PROGRAM env var — if set, the user has a clear preference.
|
|
// TERM_PROGRAM may include a .app suffix (e.g., "iTerm.app"), so strip it.
|
|
const termProgram = process.env.TERM_PROGRAM
|
|
if (termProgram) {
|
|
const normalized = termProgram.replace(/\.app$/i, '').toLowerCase()
|
|
const match = MACOS_TERMINALS.find(
|
|
t =>
|
|
t.app.toLowerCase() === normalized ||
|
|
t.name.toLowerCase() === normalized,
|
|
)
|
|
if (match) {
|
|
return { name: match.name, command: match.app }
|
|
}
|
|
}
|
|
|
|
// Check which terminals are installed by looking for .app bundles.
|
|
// Try mdfind first (Spotlight), but fall back to checking /Applications
|
|
// directly since mdfind can return empty results if Spotlight is disabled
|
|
// or hasn't indexed the app yet.
|
|
for (const terminal of MACOS_TERMINALS) {
|
|
const { code, stdout } = await execFileNoThrow(
|
|
'mdfind',
|
|
[`kMDItemCFBundleIdentifier == "${terminal.bundleId}"`],
|
|
{ timeout: 5000, useCwd: false },
|
|
)
|
|
if (code === 0 && stdout.trim().length > 0) {
|
|
return { name: terminal.name, command: terminal.app }
|
|
}
|
|
}
|
|
|
|
// Fallback: check /Applications directly (mdfind may not work if
|
|
// Spotlight indexing is disabled or incomplete)
|
|
for (const terminal of MACOS_TERMINALS) {
|
|
const { code: lsCode } = await execFileNoThrow(
|
|
'ls',
|
|
[`/Applications/${terminal.app}.app`],
|
|
{ timeout: 1000, useCwd: false },
|
|
)
|
|
if (lsCode === 0) {
|
|
return { name: terminal.name, command: terminal.app }
|
|
}
|
|
}
|
|
|
|
// Terminal.app is always available on macOS
|
|
return { name: 'Terminal.app', command: 'Terminal' }
|
|
}
|
|
|
|
/**
|
|
* Detect the user's preferred terminal on Linux.
|
|
* Checks $TERMINAL, then x-terminal-emulator, then walks a priority list.
|
|
*/
|
|
async function detectLinuxTerminal(): Promise<TerminalInfo | null> {
|
|
// Check $TERMINAL env var
|
|
const termEnv = process.env.TERMINAL
|
|
if (termEnv) {
|
|
const resolved = await which(termEnv)
|
|
if (resolved) {
|
|
return { name: basename(termEnv), command: resolved }
|
|
}
|
|
}
|
|
|
|
// Check x-terminal-emulator (Debian/Ubuntu alternative)
|
|
const xte = await which('x-terminal-emulator')
|
|
if (xte) {
|
|
return { name: 'x-terminal-emulator', command: xte }
|
|
}
|
|
|
|
// Walk the priority list
|
|
for (const terminal of LINUX_TERMINALS) {
|
|
const resolved = await which(terminal)
|
|
if (resolved) {
|
|
return { name: terminal, command: resolved }
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Detect the user's preferred terminal on Windows.
|
|
*/
|
|
async function detectWindowsTerminal(): Promise<TerminalInfo> {
|
|
// Check for Windows Terminal first
|
|
const wt = await which('wt.exe')
|
|
if (wt) {
|
|
return { name: 'Windows Terminal', command: wt }
|
|
}
|
|
|
|
// PowerShell 7+ (separate install)
|
|
const pwsh = await which('pwsh.exe')
|
|
if (pwsh) {
|
|
return { name: 'PowerShell', command: pwsh }
|
|
}
|
|
|
|
// Windows PowerShell 5.1 (built into Windows)
|
|
const powershell = await which('powershell.exe')
|
|
if (powershell) {
|
|
return { name: 'PowerShell', command: powershell }
|
|
}
|
|
|
|
// cmd.exe is always available
|
|
return { name: 'Command Prompt', command: 'cmd.exe' }
|
|
}
|
|
|
|
/**
|
|
* Detect the user's preferred terminal emulator.
|
|
*/
|
|
export async function detectTerminal(): Promise<TerminalInfo | null> {
|
|
switch (process.platform) {
|
|
case 'darwin':
|
|
return detectMacosTerminal()
|
|
case 'linux':
|
|
return detectLinuxTerminal()
|
|
case 'win32':
|
|
return detectWindowsTerminal()
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Launch Claude Code in the detected terminal emulator.
|
|
*
|
|
* Pure argv paths (no shell, user input never touches an interpreter):
|
|
* macOS — Ghostty, Alacritty, Kitty, WezTerm (via open -na --args)
|
|
* Linux — all ten in LINUX_TERMINALS
|
|
* Windows — Windows Terminal
|
|
*
|
|
* Shell-string paths (user input is shell-quoted and relied upon):
|
|
* macOS — iTerm2, Terminal.app (AppleScript `write text` / `do script`
|
|
* are inherently shell-interpreted; no argv interface exists)
|
|
* Windows — PowerShell -Command, cmd.exe /k (no argv exec mode)
|
|
*
|
|
* For pure-argv paths: claudePath, --prefill, query, cwd travel as distinct
|
|
* argv elements end-to-end. No sh -c. No shellQuote(). The terminal does
|
|
* chdir(cwd) and execvp(claude, argv). Spaces/quotes/metacharacters in
|
|
* query or cwd are preserved by argv boundaries with zero interpretation.
|
|
*/
|
|
export async function launchInTerminal(
|
|
claudePath: string,
|
|
action: {
|
|
query?: string
|
|
cwd?: string
|
|
repo?: string
|
|
lastFetchMs?: number
|
|
},
|
|
): Promise<boolean> {
|
|
const terminal = await detectTerminal()
|
|
if (!terminal) {
|
|
logForDebugging('No terminal emulator detected', { level: 'error' })
|
|
return false
|
|
}
|
|
|
|
logForDebugging(
|
|
`Launching in terminal: ${terminal.name} (${terminal.command})`,
|
|
)
|
|
const claudeArgs = ['--deep-link-origin']
|
|
if (action.repo) {
|
|
claudeArgs.push('--deep-link-repo', action.repo)
|
|
if (action.lastFetchMs !== undefined) {
|
|
claudeArgs.push('--deep-link-last-fetch', String(action.lastFetchMs))
|
|
}
|
|
}
|
|
if (action.query) {
|
|
claudeArgs.push('--prefill', action.query)
|
|
}
|
|
|
|
switch (process.platform) {
|
|
case 'darwin':
|
|
return launchMacosTerminal(terminal, claudePath, claudeArgs, action.cwd)
|
|
case 'linux':
|
|
return launchLinuxTerminal(terminal, claudePath, claudeArgs, action.cwd)
|
|
case 'win32':
|
|
return launchWindowsTerminal(terminal, claudePath, claudeArgs, action.cwd)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function launchMacosTerminal(
|
|
terminal: TerminalInfo,
|
|
claudePath: string,
|
|
claudeArgs: string[],
|
|
cwd?: string,
|
|
): Promise<boolean> {
|
|
switch (terminal.command) {
|
|
// --- SHELL-STRING PATHS (AppleScript has no argv interface) ---
|
|
// User input is shell-quoted via shellQuote(). These two are the only
|
|
// macOS paths where shellQuote() correctness is load-bearing.
|
|
|
|
case 'iTerm': {
|
|
const shCmd = buildShellCommand(claudePath, claudeArgs, cwd)
|
|
// If iTerm isn't running, `tell application` launches it and iTerm's
|
|
// default startup behavior opens a window — so `create window` would
|
|
// make a second one. Check `running` first: if already running (even
|
|
// with zero windows), create a window; if not, `activate` lets iTerm's
|
|
// startup create the first window.
|
|
const script = `tell application "iTerm"
|
|
if running then
|
|
create window with default profile
|
|
else
|
|
activate
|
|
end if
|
|
tell current session of current window
|
|
write text ${appleScriptQuote(shCmd)}
|
|
end tell
|
|
end tell`
|
|
const { code } = await execFileNoThrow('osascript', ['-e', script], {
|
|
useCwd: false,
|
|
})
|
|
if (code === 0) return true
|
|
break
|
|
}
|
|
|
|
case 'Terminal': {
|
|
const shCmd = buildShellCommand(claudePath, claudeArgs, cwd)
|
|
const script = `tell application "Terminal"
|
|
do script ${appleScriptQuote(shCmd)}
|
|
activate
|
|
end tell`
|
|
const { code } = await execFileNoThrow('osascript', ['-e', script], {
|
|
useCwd: false,
|
|
})
|
|
return code === 0
|
|
}
|
|
|
|
// --- PURE ARGV PATHS (no shell, no shellQuote) ---
|
|
// open -na <App> --args <argv> → app receives argv verbatim →
|
|
// terminal's native --working-directory + -e exec the command directly.
|
|
|
|
case 'Ghostty': {
|
|
const args = [
|
|
'-na',
|
|
terminal.command,
|
|
'--args',
|
|
'--window-save-state=never',
|
|
]
|
|
if (cwd) args.push(`--working-directory=${cwd}`)
|
|
args.push('-e', claudePath, ...claudeArgs)
|
|
const { code } = await execFileNoThrow('open', args, { useCwd: false })
|
|
if (code === 0) return true
|
|
break
|
|
}
|
|
|
|
case 'Alacritty': {
|
|
const args = ['-na', terminal.command, '--args']
|
|
if (cwd) args.push('--working-directory', cwd)
|
|
args.push('-e', claudePath, ...claudeArgs)
|
|
const { code } = await execFileNoThrow('open', args, { useCwd: false })
|
|
if (code === 0) return true
|
|
break
|
|
}
|
|
|
|
case 'kitty': {
|
|
const args = ['-na', terminal.command, '--args']
|
|
if (cwd) args.push('--directory', cwd)
|
|
args.push(claudePath, ...claudeArgs)
|
|
const { code } = await execFileNoThrow('open', args, { useCwd: false })
|
|
if (code === 0) return true
|
|
break
|
|
}
|
|
|
|
case 'WezTerm': {
|
|
const args = ['-na', terminal.command, '--args', 'start']
|
|
if (cwd) args.push('--cwd', cwd)
|
|
args.push('--', claudePath, ...claudeArgs)
|
|
const { code } = await execFileNoThrow('open', args, { useCwd: false })
|
|
if (code === 0) return true
|
|
break
|
|
}
|
|
}
|
|
|
|
logForDebugging(
|
|
`Failed to launch ${terminal.name}, falling back to Terminal.app`,
|
|
)
|
|
return launchMacosTerminal(
|
|
{ name: 'Terminal.app', command: 'Terminal' },
|
|
claudePath,
|
|
claudeArgs,
|
|
cwd,
|
|
)
|
|
}
|
|
|
|
async function launchLinuxTerminal(
|
|
terminal: TerminalInfo,
|
|
claudePath: string,
|
|
claudeArgs: string[],
|
|
cwd?: string,
|
|
): Promise<boolean> {
|
|
// All Linux paths are pure argv. Each terminal's --working-directory
|
|
// (or equivalent) sets cwd natively; the command is exec'd directly.
|
|
// For the few terminals without a cwd flag (xterm, and the opaque
|
|
// x-terminal-emulator / $TERMINAL), spawn({cwd}) sets the terminal
|
|
// process's cwd — most inherit it for the child.
|
|
|
|
let args: string[]
|
|
let spawnCwd: string | undefined
|
|
|
|
switch (terminal.name) {
|
|
case 'gnome-terminal':
|
|
args = cwd ? [`--working-directory=${cwd}`, '--'] : ['--']
|
|
args.push(claudePath, ...claudeArgs)
|
|
break
|
|
case 'konsole':
|
|
args = cwd ? ['--workdir', cwd, '-e'] : ['-e']
|
|
args.push(claudePath, ...claudeArgs)
|
|
break
|
|
case 'kitty':
|
|
args = cwd ? ['--directory', cwd] : []
|
|
args.push(claudePath, ...claudeArgs)
|
|
break
|
|
case 'wezterm':
|
|
args = cwd ? ['start', '--cwd', cwd, '--'] : ['start', '--']
|
|
args.push(claudePath, ...claudeArgs)
|
|
break
|
|
case 'alacritty':
|
|
args = cwd ? ['--working-directory', cwd, '-e'] : ['-e']
|
|
args.push(claudePath, ...claudeArgs)
|
|
break
|
|
case 'ghostty':
|
|
args = cwd ? [`--working-directory=${cwd}`, '-e'] : ['-e']
|
|
args.push(claudePath, ...claudeArgs)
|
|
break
|
|
case 'xfce4-terminal':
|
|
case 'mate-terminal':
|
|
args = cwd ? [`--working-directory=${cwd}`, '-x'] : ['-x']
|
|
args.push(claudePath, ...claudeArgs)
|
|
break
|
|
case 'tilix':
|
|
args = cwd ? [`--working-directory=${cwd}`, '-e'] : ['-e']
|
|
args.push(claudePath, ...claudeArgs)
|
|
break
|
|
default:
|
|
// xterm, x-terminal-emulator, $TERMINAL — no reliable cwd flag.
|
|
// spawn({cwd}) sets the terminal's own cwd; most inherit.
|
|
args = ['-e', claudePath, ...claudeArgs]
|
|
spawnCwd = cwd
|
|
break
|
|
}
|
|
|
|
return spawnDetached(terminal.command, args, { cwd: spawnCwd })
|
|
}
|
|
|
|
async function launchWindowsTerminal(
|
|
terminal: TerminalInfo,
|
|
claudePath: string,
|
|
claudeArgs: string[],
|
|
cwd?: string,
|
|
): Promise<boolean> {
|
|
const args: string[] = []
|
|
|
|
switch (terminal.name) {
|
|
// --- PURE ARGV PATH ---
|
|
case 'Windows Terminal':
|
|
if (cwd) args.push('-d', cwd)
|
|
args.push('--', claudePath, ...claudeArgs)
|
|
break
|
|
|
|
// --- SHELL-STRING PATHS ---
|
|
// PowerShell -Command and cmd /k take a command string. No argv exec
|
|
// mode that also keeps the session interactive after claude exits.
|
|
// User input is escaped per-shell; correctness of that escaping is
|
|
// load-bearing here.
|
|
|
|
case 'PowerShell': {
|
|
// Single-quoted PowerShell strings have NO escape sequences (only
|
|
// '' for a literal quote). Double-quoted strings interpret backtick
|
|
// escapes — a query containing `" could break out.
|
|
const cdCmd = cwd ? `Set-Location ${psQuote(cwd)}; ` : ''
|
|
args.push(
|
|
'-NoExit',
|
|
'-Command',
|
|
`${cdCmd}& ${psQuote(claudePath)} ${claudeArgs.map(psQuote).join(' ')}`,
|
|
)
|
|
break
|
|
}
|
|
|
|
default: {
|
|
const cdCmd = cwd ? `cd /d ${cmdQuote(cwd)} && ` : ''
|
|
args.push(
|
|
'/k',
|
|
`${cdCmd}${cmdQuote(claudePath)} ${claudeArgs.map(a => cmdQuote(a)).join(' ')}`,
|
|
)
|
|
break
|
|
}
|
|
}
|
|
|
|
// cmd.exe does NOT use MSVCRT-style argument parsing. libuv's default
|
|
// quoting for spawn() on Windows assumes MSVCRT rules and would double-
|
|
// escape our already-cmdQuote'd string. Bypass it for cmd.exe only.
|
|
return spawnDetached(terminal.command, args, {
|
|
windowsVerbatimArguments: terminal.name === 'Command Prompt',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Spawn a terminal detached so the handler process can exit without
|
|
* waiting for the terminal to close. Resolves false on spawn failure
|
|
* (ENOENT, EACCES) rather than crashing.
|
|
*/
|
|
function spawnDetached(
|
|
command: string,
|
|
args: string[],
|
|
opts: { cwd?: string; windowsVerbatimArguments?: boolean } = {},
|
|
): Promise<boolean> {
|
|
return new Promise<boolean>(resolve => {
|
|
const child = spawn(command, args, {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
cwd: opts.cwd,
|
|
windowsVerbatimArguments: opts.windowsVerbatimArguments,
|
|
})
|
|
child.once('error', err => {
|
|
logForDebugging(`Failed to spawn ${command}: ${err.message}`, {
|
|
level: 'error',
|
|
})
|
|
void resolve(false)
|
|
})
|
|
child.once('spawn', () => {
|
|
child.unref()
|
|
void resolve(true)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Build a single-quoted POSIX shell command string. ONLY used by the
|
|
* AppleScript paths (iTerm, Terminal.app) which have no argv interface.
|
|
*/
|
|
function buildShellCommand(
|
|
claudePath: string,
|
|
claudeArgs: string[],
|
|
cwd?: string,
|
|
): string {
|
|
const cdPrefix = cwd ? `cd ${shellQuote(cwd)} && ` : ''
|
|
return `${cdPrefix}${[claudePath, ...claudeArgs].map(shellQuote).join(' ')}`
|
|
}
|
|
|
|
/**
|
|
* POSIX single-quote escaping. Single-quoted strings have zero
|
|
* interpretation except for the closing single quote itself.
|
|
* Only used by buildShellCommand() for the AppleScript paths.
|
|
*/
|
|
function shellQuote(s: string): string {
|
|
return `'${s.replace(/'/g, "'\\''")}'`
|
|
}
|
|
|
|
/**
|
|
* AppleScript string literal escaping (backslash then double-quote).
|
|
*/
|
|
function appleScriptQuote(s: string): string {
|
|
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
}
|
|
|
|
/**
|
|
* PowerShell single-quoted string. The ONLY special sequence is '' for a
|
|
* literal single quote — no backtick escapes, no variable expansion, no
|
|
* subexpressions. This is the safe PowerShell quoting; double-quoted
|
|
* strings interpret `n `t `" etc. and can be escaped out of.
|
|
*/
|
|
function psQuote(s: string): string {
|
|
return `'${s.replace(/'/g, "''")}'`
|
|
}
|
|
|
|
/**
|
|
* cmd.exe argument quoting. cmd.exe does NOT use CommandLineToArgvW-style
|
|
* backslash escaping — it toggles its quoting state on every raw "
|
|
* character, so an embedded " breaks out of the quoted region and exposes
|
|
* metacharacters (& | < > ^) to cmd.exe interpretation = command injection.
|
|
*
|
|
* Strategy: strip " from the input (it cannot be safely represented in a
|
|
* cmd.exe double-quoted string). Escape % as %% to prevent environment
|
|
* variable expansion (%PATH% etc.) which cmd.exe performs even inside
|
|
* double quotes. Trailing backslashes are still doubled because the
|
|
* *child process* (claude.exe) uses CommandLineToArgvW, where a trailing
|
|
* \ before our closing " would eat the close-quote.
|
|
*/
|
|
function cmdQuote(arg: string): string {
|
|
const stripped = arg.replace(/"/g, '').replace(/%/g, '%%')
|
|
const escaped = stripped.replace(/(\\+)$/, '$1$1')
|
|
return `"${escaped}"`
|
|
}
|