source dump of claude code
at main 183 lines 6.6 kB view raw
1import { 2 type SpawnOptions, 3 type SpawnSyncOptions, 4 spawn, 5 spawnSync, 6} from 'child_process' 7import memoize from 'lodash-es/memoize.js' 8import { basename } from 'path' 9import instances from '../ink/instances.js' 10import { logForDebugging } from './debug.js' 11import { whichSync } from './which.js' 12 13function isCommandAvailable(command: string): boolean { 14 return !!whichSync(command) 15} 16 17// GUI editors that open in a separate window and can be spawned detached 18// without fighting the TUI for stdin. VS Code forks (cursor, windsurf, codium) 19// are listed explicitly since none contain 'code' as a substring. 20const GUI_EDITORS = [ 21 'code', 22 'cursor', 23 'windsurf', 24 'codium', 25 'subl', 26 'atom', 27 'gedit', 28 'notepad++', 29 'notepad', 30] 31 32// Editors that accept +N as a goto-line argument. The Windows default 33// ('start /wait notepad') does not — notepad treats +42 as a filename. 34const PLUS_N_EDITORS = /\b(vi|vim|nvim|nano|emacs|pico|micro|helix|hx)\b/ 35 36// VS Code and forks use -g file:line. subl uses bare file:line (no -g). 37const VSCODE_FAMILY = new Set(['code', 'cursor', 'windsurf', 'codium']) 38 39/** 40 * Classify the editor as GUI or not. Returns the matched GUI family name 41 * for goto-line argv selection, or undefined for terminal editors. 42 * Note: this is classification only — spawn the user's actual binary, not 43 * this return value, so `code-insiders` / absolute paths are preserved. 44 * 45 * Uses basename so /home/alice/code/bin/nvim doesn't match 'code' via the 46 * directory component. code-insiders → still matches 'code', /usr/bin/code → 47 * 'code' → matches. 48 */ 49export function classifyGuiEditor(editor: string): string | undefined { 50 const base = basename(editor.split(' ')[0] ?? '') 51 return GUI_EDITORS.find(g => base.includes(g)) 52} 53 54/** 55 * Build goto-line argv for a GUI editor. VS Code family uses -g file:line; 56 * subl uses bare file:line; others don't support goto-line. 57 */ 58function guiGotoArgv( 59 guiFamily: string, 60 filePath: string, 61 line: number | undefined, 62): string[] { 63 if (!line) return [filePath] 64 if (VSCODE_FAMILY.has(guiFamily)) return ['-g', `${filePath}:${line}`] 65 if (guiFamily === 'subl') return [`${filePath}:${line}`] 66 return [filePath] 67} 68 69/** 70 * Launch a file in the user's external editor. 71 * 72 * For GUI editors (code, subl, etc.): spawns detached — the editor opens 73 * in a separate window and Claude Code stays interactive. 74 * 75 * For terminal editors (vim, nvim, nano, etc.): blocks via Ink's alt-screen 76 * handoff until the editor exits. This is the same dance as editFileInEditor() 77 * in promptEditor.ts, minus the read-back. 78 * 79 * Returns true if the editor was launched, false if no editor is available. 80 */ 81export function openFileInExternalEditor( 82 filePath: string, 83 line?: number, 84): boolean { 85 const editor = getExternalEditor() 86 if (!editor) return false 87 88 // Spawn the user's actual binary (preserves code-insiders, abs paths, etc.). 89 // Split into binary + extra args so multi-word values like 'start /wait 90 // notepad' or 'code --wait' propagate all tokens to spawn. 91 const parts = editor.split(' ') 92 const base = parts[0] ?? editor 93 const editorArgs = parts.slice(1) 94 const guiFamily = classifyGuiEditor(editor) 95 96 if (guiFamily) { 97 const gotoArgv = guiGotoArgv(guiFamily, filePath, line) 98 const detachedOpts: SpawnOptions = { detached: true, stdio: 'ignore' } 99 let child 100 if (process.platform === 'win32') { 101 // shell: true on win32 so code.cmd / cursor.cmd / windsurf.cmd resolve — 102 // CreateProcess can't execute .cmd/.bat directly. Assemble quoted command 103 // string; cmd.exe doesn't expand $() or backticks inside double quotes. 104 // Quote each arg so paths with spaces survive the shell join. 105 const gotoStr = gotoArgv.map(a => `"${a}"`).join(' ') 106 child = spawn(`${editor} ${gotoStr}`, { ...detachedOpts, shell: true }) 107 } else { 108 // POSIX: argv array with no shell — injection-safe. shell: true would 109 // expand $() / backticks inside double quotes, and filePath is 110 // filesystem-sourced (possible RCE from a malicious repo filename). 111 child = spawn(base, [...editorArgs, ...gotoArgv], detachedOpts) 112 } 113 // spawn() emits ENOENT asynchronously. ENOENT on $VISUAL/$EDITOR is a 114 // user-config error, not an internal bug — don't pollute error telemetry. 115 child.on('error', e => 116 logForDebugging(`editor spawn failed: ${e}`, { level: 'error' }), 117 ) 118 child.unref() 119 return true 120 } 121 122 // Terminal editor — needs alt-screen handoff since it takes over the 123 // terminal. Blocks until the editor exits. 124 const inkInstance = instances.get(process.stdout) 125 if (!inkInstance) return false 126 // Only prepend +N for editors known to support it — notepad treats +42 as a 127 // filename to open. Test basename so /home/vim/bin/kak doesn't match 'vim' 128 // via the directory segment. 129 const useGotoLine = line && PLUS_N_EDITORS.test(basename(base)) 130 inkInstance.enterAlternateScreen() 131 try { 132 const syncOpts: SpawnSyncOptions = { stdio: 'inherit' } 133 let result 134 if (process.platform === 'win32') { 135 // On Windows use shell: true so cmd.exe builtins like `start` resolve. 136 // shell: true joins args unquoted, so assemble the command string with 137 // explicit quoting ourselves (matching promptEditor.ts:74). spawnSync 138 // returns errors in .error rather than throwing. 139 const lineArg = useGotoLine ? `+${line} ` : '' 140 result = spawnSync(`${editor} ${lineArg}"${filePath}"`, { 141 ...syncOpts, 142 shell: true, 143 }) 144 } else { 145 // POSIX: spawn directly (no shell), argv array is quote-safe. 146 const args = [ 147 ...editorArgs, 148 ...(useGotoLine ? [`+${line}`, filePath] : [filePath]), 149 ] 150 result = spawnSync(base, args, syncOpts) 151 } 152 if (result.error) { 153 logForDebugging(`editor spawn failed: ${result.error}`, { 154 level: 'error', 155 }) 156 return false 157 } 158 return true 159 } finally { 160 inkInstance.exitAlternateScreen() 161 } 162} 163 164export const getExternalEditor = memoize((): string | undefined => { 165 // Prioritize environment variables 166 if (process.env.VISUAL?.trim()) { 167 return process.env.VISUAL.trim() 168 } 169 170 if (process.env.EDITOR?.trim()) { 171 return process.env.EDITOR.trim() 172 } 173 174 // `isCommandAvailable` breaks the claude process' stdin on Windows 175 // as a bandaid, we skip it 176 if (process.platform === 'win32') { 177 return 'start /wait notepad' 178 } 179 180 // Search for available editors in order of preference 181 const editors = ['code', 'vi', 'nano'] 182 return editors.find(command => isCommandAvailable(command)) 183})