From f3ce0957cd464fe4f90395fbd701327d15c3dd84 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 09:52:52 -0400 Subject: [PATCH] feat: multi-runtime selection in interactive installer (#1281) The interactive prompt now accepts comma-separated or space-separated choices (e.g., "1,4,6" or "1 4 6") to install multiple runtimes in one go, without needing --all or running the installer multiple times. - Replaced if/else-if chain with runtimeMap lookup + split parser - Added hint text: "Select multiple: 1,4,6 or 1 4 6" - Invalid choices silently filtered, duplicates deduplicated - Empty input still defaults to Claude Code - Choice "8" still selects all runtimes Tests: 10 new tests covering comma/space/mixed separators, deduplication, invalid input filtering, order preservation, and source-level assertions. Closes #1281 Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/install.js | 48 +++++---- tests/copilot-install.test.cjs | 15 +-- tests/multi-runtime-select.test.cjs | 145 ++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 tests/multi-runtime-select.test.cjs diff --git a/bin/install.js b/bin/install.js index d4ded0b27..c65d9c81d 100755 --- a/bin/install.js +++ b/bin/install.js @@ -4283,6 +4283,17 @@ function promptRuntime(callback) { } }); + const runtimeMap = { + '1': 'claude', + '2': 'opencode', + '3': 'gemini', + '4': 'codex', + '5': 'copilot', + '6': 'antigravity', + '7': 'cursor' + }; + const allRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor']; + console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset} @@ -4291,29 +4302,32 @@ function promptRuntime(callback) { ${cyan}6${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset} ${cyan}7${reset}) Cursor ${dim}(~/.cursor)${reset} ${cyan}8${reset}) All + + ${dim}Select multiple: 1,4,6 or 1 4 6${reset} `); rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { answered = true; rl.close(); - const choice = answer.trim() || '1'; - if (choice === '8') { - callback(['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor']); - } else if (choice === '7') { - callback(['cursor']); - } else if (choice === '6') { - callback(['antigravity']); - } else if (choice === '5') { - callback(['copilot']); - } else if (choice === '4') { - callback(['codex']); - } else if (choice === '3') { - callback(['gemini']); - } else if (choice === '2') { - callback(['opencode']); - } else { - callback(['claude']); + const input = answer.trim() || '1'; + + // "All" shortcut + if (input === '8') { + callback(allRuntimes); + return; } + + // Parse comma-separated, space-separated, or single choice + const choices = input.split(/[\s,]+/).filter(Boolean); + const selected = []; + for (const c of choices) { + const runtime = runtimeMap[c]; + if (runtime && !selected.includes(runtime)) { + selected.push(runtime); + } + } + + callback(selected.length > 0 ? selected : ['claude']); }); } diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index ad8185ea2..bebef833c 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -150,18 +150,13 @@ describe('Source code integration (Copilot)', () => { assert.ok(src.includes('--copilot'), 'help text has --copilot option'); }); - test('CLI-02: promptRuntime has Copilot as option 5', () => { - assert.ok(src.includes("choice === '5'"), 'choice 5 exists'); - // Verify choice 5 maps to copilot (the line after choice === '5' should reference copilot) - const choice5Index = src.indexOf("choice === '5'"); - const nextLines = src.substring(choice5Index, choice5Index + 100); - assert.ok(nextLines.includes('copilot'), 'choice 5 maps to copilot'); + test('CLI-02: promptRuntime runtimeMap has Copilot as option 5', () => { + assert.ok(src.includes("'5': 'copilot'"), 'runtimeMap has 5 -> copilot'); }); - test('CLI-02: promptRuntime has All option including copilot', () => { - // All option callback includes copilot in the runtimes array - const allCallbackMatch = src.match(/callback\(\[(['a-z', ]+)\]\)/g); - assert.ok(allCallbackMatch && allCallbackMatch.some(m => m.includes('copilot')), 'All option includes copilot'); + test('CLI-02: promptRuntime allRuntimes array includes copilot', () => { + const allMatch = src.match(/const allRuntimes = \[([^\]]+)\]/); + assert.ok(allMatch && allMatch[1].includes('copilot'), 'allRuntimes includes copilot'); }); test('isCopilot variable exists in install function', () => { diff --git a/tests/multi-runtime-select.test.cjs b/tests/multi-runtime-select.test.cjs new file mode 100644 index 000000000..3befa8308 --- /dev/null +++ b/tests/multi-runtime-select.test.cjs @@ -0,0 +1,145 @@ +/** + * Tests for multi-runtime selection in the interactive installer prompt. + * Verifies that promptRuntime accepts comma-separated, space-separated, + * and single-choice inputs, deduplicates, and falls back to claude. + * See issue #1281. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +// Read install.js source to extract the runtimeMap and parsing logic +const installSrc = fs.readFileSync( + path.join(__dirname, '..', 'bin', 'install.js'), + 'utf8' +); + +// Extract runtimeMap from source for validation +const runtimeMap = { + '1': 'claude', + '2': 'opencode', + '3': 'gemini', + '4': 'codex', + '5': 'copilot', + '6': 'antigravity', + '7': 'cursor' +}; +const allRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor']; + +/** + * Simulate the parsing logic from promptRuntime without requiring readline. + * This mirrors the exact logic in the rl.question callback. + */ +function parseRuntimeInput(input) { + input = input.trim() || '1'; + + if (input === '8') { + return allRuntimes; + } + + const choices = input.split(/[\s,]+/).filter(Boolean); + const selected = []; + for (const c of choices) { + const runtime = runtimeMap[c]; + if (runtime && !selected.includes(runtime)) { + selected.push(runtime); + } + } + + return selected.length > 0 ? selected : ['claude']; +} + +describe('multi-runtime selection parsing', () => { + test('single choice returns single runtime', () => { + assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']); + assert.deepStrictEqual(parseRuntimeInput('4'), ['codex']); + assert.deepStrictEqual(parseRuntimeInput('7'), ['cursor']); + }); + + test('comma-separated choices return multiple runtimes', () => { + assert.deepStrictEqual(parseRuntimeInput('1,4,6'), ['claude', 'codex', 'antigravity']); + assert.deepStrictEqual(parseRuntimeInput('2,3'), ['opencode', 'gemini']); + }); + + test('space-separated choices return multiple runtimes', () => { + assert.deepStrictEqual(parseRuntimeInput('1 4 6'), ['claude', 'codex', 'antigravity']); + assert.deepStrictEqual(parseRuntimeInput('5 7'), ['copilot', 'cursor']); + }); + + test('mixed comma and space separators work', () => { + assert.deepStrictEqual(parseRuntimeInput('1, 4, 6'), ['claude', 'codex', 'antigravity']); + assert.deepStrictEqual(parseRuntimeInput('2 , 5'), ['opencode', 'copilot']); + }); + + test('choice 8 returns all runtimes', () => { + assert.deepStrictEqual(parseRuntimeInput('8'), allRuntimes); + }); + + test('empty input defaults to claude', () => { + assert.deepStrictEqual(parseRuntimeInput(''), ['claude']); + assert.deepStrictEqual(parseRuntimeInput(' '), ['claude']); + }); + + test('invalid choices are ignored, falls back to claude if all invalid', () => { + assert.deepStrictEqual(parseRuntimeInput('9'), ['claude']); + assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']); + assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']); + }); + + test('invalid choices mixed with valid are filtered out', () => { + assert.deepStrictEqual(parseRuntimeInput('1,9,4'), ['claude', 'codex']); + assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['gemini']); + }); + + test('duplicate choices are deduplicated', () => { + assert.deepStrictEqual(parseRuntimeInput('1,1,1'), ['claude']); + assert.deepStrictEqual(parseRuntimeInput('4,4,6,6'), ['codex', 'antigravity']); + }); + + test('preserves selection order', () => { + assert.deepStrictEqual(parseRuntimeInput('6,1,4'), ['antigravity', 'claude', 'codex']); + assert.deepStrictEqual(parseRuntimeInput('7,2,5'), ['cursor', 'opencode', 'copilot']); + }); +}); + +describe('install.js source contains multi-select support', () => { + test('runtimeMap is defined with all 7 runtimes', () => { + for (const [key, name] of Object.entries(runtimeMap)) { + assert.ok( + installSrc.includes(`'${key}': '${name}'`), + `runtimeMap has ${key} -> ${name}` + ); + } + }); + + test('allRuntimes array contains all runtimes', () => { + const match = installSrc.match(/const allRuntimes = \[([^\]]+)\]/); + assert.ok(match, 'allRuntimes array found'); + for (const rt of allRuntimes) { + assert.ok(match[1].includes(`'${rt}'`), `allRuntimes includes ${rt}`); + } + }); + + test('prompt text shows multi-select hint', () => { + assert.ok( + installSrc.includes('Select multiple'), + 'prompt includes multi-select instructions' + ); + }); + + test('parsing uses split with comma and space regex', () => { + assert.ok( + installSrc.includes("split(/[\\s,]+/)"), + 'input is split on commas and whitespace' + ); + }); + + test('deduplication check exists', () => { + assert.ok( + installSrc.includes('!selected.includes(runtime)'), + 'deduplication guard exists' + ); + }); +});