#!/usr/bin/env node 'use strict'; const path = require('path'); const childProcess = require('child_process'); const DEFAULT_TERMINAL = 'wezterm'; const SPAWN_KILL_SIGNAL = 'SIGTERM'; const SYNC_TIMEOUT_MS = 10_000; const SUPPORTED_TERMINALS = new Set([DEFAULT_TERMINAL]); function usage() { return `Open an executable and its argument array in a visible terminal. Usage: node skills/terminal-opener/scripts/open-terminal.js [options] -- [args...] node skills/terminal-opener/scripts/open-terminal.js --detect [--terminal ] [--json] Options: --terminal Terminal adapter (default: ECC_TERMINAL or wezterm). --cwd Initial host directory (default: current directory). --recover Start a standalone terminal with stock configuration. --standalone Alias for --recover. --detect Check whether the selected terminal can be launched. --launch Explicitly open the terminal (the default only prints a plan). --dry-run Explicitly print the launch plan without opening a terminal. --json Emit the plan, capability, or launch result as JSON. --help, -h Show this help. Always pass the executable and arguments as separate entries after --. Shell command strings are not accepted. `; } function isAbsolutePath(value) { return path.isAbsolute(value) || path.win32.isAbsolute(value); } function validateTerminalName(value) { if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value)) { throw new Error('Invalid terminal name; use a simple adapter name such as wezterm.'); } } function validateCwd(value) { if (value.includes('\0')) throw new Error('--cwd must not contain a NUL byte.'); if (!isAbsolutePath(value)) throw new Error('--cwd must be an absolute path.'); } function validateExecutable(value) { if (!value || /[\0\r\n]/.test(value)) { throw new Error('Executable must be a non-empty argv entry without control bytes.'); } const whitespaceIndex = value.search(/\s/); const separatorIndexes = [value.indexOf('/'), value.indexOf('\\')].filter(index => index >= 0); const firstSeparatorIndex = separatorIndexes.length > 0 ? Math.min(...separatorIndexes) : -1; const resemblesExecutablePath = isAbsolutePath(value) || (firstSeparatorIndex >= 0 && (whitespaceIndex < 0 || firstSeparatorIndex < whitespaceIndex)); if (whitespaceIndex >= 0 && !resemblesExecutablePath) { throw new Error( 'Executable must be one argv entry, not an interpolated shell command string.' ); } if (!resemblesExecutablePath && /[;&|<>`$]/.test(value)) { throw new Error( 'Executable must be one argv entry, not an interpolated shell command string.' ); } } function validateArgv(argv) { for (const argument of argv) { if (argument.includes('\0')) throw new Error('Arguments must not contain NUL bytes.'); } } function readValue(argv, index, option) { const value = argv[index + 1]; if (value === undefined || value.startsWith('--')) { throw new Error(`Missing value for ${option}.`); } return value; } function parseArgs(argv, context = {}) { const env = context.env || process.env; const initialTerminal = env.ECC_TERMINAL || DEFAULT_TERMINAL; const initialCwd = context.cwd || process.cwd(); const options = { argv: [], cwd: initialCwd, detect: false, dryRun: true, executable: undefined, help: false, json: false, mode: 'normal', terminal: initialTerminal, }; let dryRunRequested = false; let launchRequested = false; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--') { options.executable = argv[index + 1]; options.argv = argv.slice(index + 2); break; } if (argument === '--terminal' || argument === '--cwd') { const value = readValue(argv, index, argument); options[argument.slice(2)] = value; index += 1; } else if (argument === '--recover' || argument === '--standalone') { options.mode = 'recover'; } else if (argument === '--detect') { options.detect = true; } else if (argument === '--launch') { launchRequested = true; options.dryRun = false; } else if (argument === '--dry-run') { dryRunRequested = true; options.dryRun = true; } else if (argument === '--json') { options.json = true; } else if (argument === '--help' || argument === '-h') { options.help = true; } else { throw new Error(`Unknown option "${argument}"; put the executable after --.`); } } if (launchRequested && dryRunRequested) { throw new Error('--launch and --dry-run are mutually exclusive.'); } validateTerminalName(options.terminal); validateCwd(options.cwd); if (!options.help && !options.detect && !options.executable) { throw new Error('An executable is required after --.'); } if (options.executable) validateExecutable(options.executable); validateArgv(options.argv); return options; } function unsupportedPlan(options) { return { ok: false, reason: 'unsupported-terminal', action: `Terminal "${options.terminal}" is not supported. Install WezTerm, then rerun with --terminal wezterm.`, terminal: options.terminal, executable: options.executable, argv: [...options.argv], cwd: options.cwd, dryRun: options.dryRun, launchMode: options.mode === 'recover' ? 'recover' : 'mux', command: null, args: null, fallback: null, probe: null, }; } function buildLaunchPlan(options) { if (!SUPPORTED_TERMINALS.has(options.terminal)) return unsupportedPlan(options); const commandArgs = options.executable ? [options.executable, ...options.argv] : []; const recover = options.mode === 'recover'; return { ok: true, reason: null, action: null, terminal: options.terminal, executable: options.executable, argv: [...options.argv], cwd: options.cwd, dryRun: options.dryRun, launchMode: recover ? 'recover' : 'mux', command: DEFAULT_TERMINAL, args: recover ? ['--skip-config', 'start', '--always-new-process', '--cwd', options.cwd, '--', ...commandArgs] : ['cli', 'spawn', '--new-window', '--cwd', options.cwd, '--', ...commandArgs], fallback: recover ? null : { command: DEFAULT_TERMINAL, args: ['start', '--cwd', options.cwd, '--', ...commandArgs], }, probe: { command: DEFAULT_TERMINAL, args: ['--version'] }, }; } function unavailableCapability(plan, reason, detail) { return { terminal: plan.terminal, supported: true, available: false, reason, detail, action: 'Install WezTerm and ensure wezterm is on PATH, then rerun with --detect.', }; } function detectTerminalCapability(plan, spawnSyncImpl = childProcess.spawnSync) { if (!plan.ok) { return { terminal: plan.terminal, supported: false, available: false, reason: plan.reason, detail: null, action: plan.action, }; } let result; try { result = spawnSyncImpl(plan.probe.command, plan.probe.args, { encoding: 'utf8', killSignal: SPAWN_KILL_SIGNAL, shell: false, timeout: SYNC_TIMEOUT_MS, }); } catch (error) { return unavailableCapability(plan, 'probe-failed', error.message); } if (result.error) { const reason = result.error.code === 'ETIMEDOUT' ? 'probe-failed' : 'not-installed'; return unavailableCapability(plan, reason, result.error.message); } if (result.status !== 0) { return unavailableCapability( plan, 'probe-failed', `Terminal version probe exited with status ${result.status}.` ); } return { terminal: plan.terminal, supported: true, available: true, reason: null, detail: null, action: null, version: String(result.stdout || '').trim(), }; } function reportDetachedError(error) { process.stderr.write(`Error: ${error.message}\n`); process.exitCode = 1; } function launchDetached(command, args, cwd, spawnImpl, onDetachedError) { let child; try { child = spawnImpl(command, args, { cwd, detached: true, shell: false, stdio: 'ignore', }); } catch (error) { throw new Error(`Unable to start ${command}: ${error.message}`, { cause: error }); } if (!child || typeof child.unref !== 'function') { throw new Error('Terminal process did not start correctly.'); } if (typeof child.once === 'function') { child.once('error', error => { onDetachedError( new Error(`Unable to start ${command}: ${error.message}`, { cause: error }) ); }); } child.unref(); } function launch(plan, dependencies = {}) { const spawnSyncImpl = dependencies.spawnSync || childProcess.spawnSync; const spawnImpl = dependencies.spawn || childProcess.spawn; const onDetachedError = dependencies.onDetachedError || reportDetachedError; const capability = detectTerminalCapability(plan, spawnSyncImpl); if (!capability.available) { throw new Error(`${capability.reason}: ${capability.action}`); } if (plan.launchMode === 'recover') { launchDetached(plan.command, plan.args, plan.cwd, spawnImpl, onDetachedError); return { strategy: 'detached-recover', capability }; } const muxResult = spawnSyncImpl(plan.command, plan.args, { cwd: plan.cwd, encoding: 'utf8', killSignal: SPAWN_KILL_SIGNAL, shell: false, timeout: SYNC_TIMEOUT_MS, }); if (!muxResult.error && muxResult.status === 0) { return { strategy: 'mux', capability }; } const muxFailure = muxResult.error ? muxResult.error.message : `${plan.command} cli spawn exited with status ${muxResult.status}: ${String( muxResult.stderr || '' ).trim()}`; launchDetached( plan.fallback.command, plan.fallback.args, plan.cwd, spawnImpl, onDetachedError ); return { strategy: 'detached-fallback', capability, muxFailure }; } function printJson(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } function formatLaunchResult(plan, result, json) { if (json) { return `${JSON.stringify({ ...plan, ...result }, null, 2)}\n`; } const summary = `Open ${plan.executable} in ${plan.terminal} using ${plan.launchMode} mode.\n`; if (result.strategy !== 'detached-fallback') return summary; return `${summary}Mux launch failed: ${result.muxFailure}\n`; } function printPlan(plan, json) { if (json) return printJson(plan); if (!plan.ok) { process.stdout.write(`${plan.action}\n`); return; } process.stdout.write( `Open ${plan.executable} in ${plan.terminal} using ${plan.launchMode} mode.\n` ); } function printCapability(capability, json) { if (json) return printJson(capability); if (capability.available) { process.stdout.write(`${capability.terminal} is available (${capability.version}).\n`); } else { process.stdout.write(`${capability.terminal} is unavailable. ${capability.action}\n`); } } function main() { try { const options = parseArgs(process.argv.slice(2)); if (options.help) { process.stdout.write(usage()); return; } const plan = buildLaunchPlan(options); if (options.detect) { const capability = detectTerminalCapability(plan); printCapability(capability, options.json); if (!capability.available) process.exitCode = 1; return; } if (options.dryRun) { printPlan(plan, options.json); return; } const result = launch(plan); process.stdout.write(formatLaunchResult(plan, result, options.json)); } catch (error) { process.stderr.write(`Error: ${error.message}\n`); process.exitCode = 1; } } if (require.main === module) main(); module.exports = { buildLaunchPlan, detectTerminalCapability, formatLaunchResult, launch, parseArgs, usage, };