Download src/utils/deepLink/terminalLauncher.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 17.8 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/deepLink/terminalLauncher.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/deepLink/terminalLauncher.ts
-
curl -L -o terminalLauncher.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/deepLink/terminalLauncher.ts
17.8 kB
| /** | |
| * 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}"` | |
| } | |