scripts/open-terminal.js
#!/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] -- <executable> [args...]
node skills/terminal-opener/scripts/open-terminal.js --detect [--terminal <name>] [--json]
Options:
--terminal <name> Terminal adapter (default: ECC_TERMINAL or wezterm).
--cwd <path> 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,
};