start-ai-cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +54 -5
  2. package/bin/start-ai-cli.js +511 -112
  3. package/package.json +48 -41
package/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/start-ai-cli.svg)](https://www.npmjs.com/package/start-ai-cli)
5
5
  [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
6
 
7
- `start-ai-cli` is an npm package and command line tool for launching multiple AI coding CLIs from the same project directory. It opens Codex CLI, Claude Code, and Cursor CLI in separate terminal tabs or windows so every assistant starts in the current workspace.
7
+ `start-ai-cli` is an npm package and command line tool for launching selected AI coding CLIs from the same project directory. It can open Codex CLI, Claude Code, and Cursor CLI in separate terminal tabs or windows so every assistant starts in the current workspace.
8
8
 
9
- Use it when you want one command to start your AI coding agents:
9
+ Use it when you want one command to choose and start your AI coding agents:
10
10
 
11
11
  ```bash
12
12
  npx start-ai-cli
@@ -14,11 +14,11 @@ npx start-ai-cli
14
14
 
15
15
  ## Features
16
16
 
17
- - Launches Codex CLI, Claude Code CLI, and Cursor CLI from the current directory.
17
+ - Interactively chooses which Codex CLI, Claude Code CLI, and Cursor CLI tools to launch.
18
+ - Remembers your enabled tools in a global config file for the next run.
18
19
  - Opens each available tool in its own Windows Terminal tab or macOS Terminal window.
19
20
  - Skips missing CLIs instead of failing when at least one supported tool is available.
20
21
  - Works as a global npm command or one-off `npx` command.
21
- - Has no runtime dependencies.
22
22
 
23
23
  ## Install
24
24
 
@@ -53,7 +53,7 @@ On Windows, `start-ai-cli` uses Windows Terminal (`wt.exe`) with PowerShell. On
53
53
  ## Requirements
54
54
 
55
55
  - Windows or macOS
56
- - Node.js 18 or newer
56
+ - Node.js 22 or newer
57
57
  - Windows Terminal available as `wt.exe` on Windows
58
58
  - PowerShell 7 available as `pwsh.exe`, or Windows PowerShell, on Windows
59
59
  - Terminal.app and `osascript` available on macOS
@@ -62,10 +62,35 @@ On Windows, `start-ai-cli` uses Windows Terminal (`wt.exe`) with PowerShell. On
62
62
  ## Options
63
63
 
64
64
  ```bash
65
+ start-ai-cli
66
+ start-ai-cli --all
67
+ start-ai-cli --no-interactive
65
68
  start-ai-cli --help
66
69
  start-ai-cli --version
67
70
  ```
68
71
 
72
+ By default, `start-ai-cli` opens an interactive selector. Use Up/Down to move, Space to enable or disable an available CLI, Enter to open the selected tools, or `q`/Esc to cancel. Missing CLIs are shown as unavailable and cannot be selected.
73
+
74
+ Use `--all` or `--no-interactive` in scripts and automation to launch every available CLI without prompting.
75
+
76
+ ## Global Config
77
+
78
+ Interactive selections are saved to:
79
+
80
+ ```text
81
+ ~/.start-ai-cli/config.json
82
+ ```
83
+
84
+ The config stores enabled tool ids:
85
+
86
+ ```json
87
+ {
88
+ "enabledClis": ["codex", "claude", "cursor"]
89
+ }
90
+ ```
91
+
92
+ Delete this file to reset the saved defaults.
93
+
69
94
  ## Troubleshooting
70
95
 
71
96
  ### `start-ai-cli cannot start: no CLI tools found in PATH`
@@ -76,6 +101,30 @@ Install or expose at least one supported command in your shell:
76
101
  - `claude` for Claude Code
77
102
  - `agent` for Cursor CLI
78
103
 
104
+ ### A CLI does not appear as available in the prompt
105
+
106
+ Make sure its command is installed and available in `PATH`:
107
+
108
+ - `codex` for Codex CLI
109
+ - `claude` for Claude Code
110
+ - `agent` for Cursor CLI
111
+
112
+ ### `start-ai-cli cannot prompt in a non-interactive terminal`
113
+
114
+ Run with `--all` or `--no-interactive` when using CI, scripts, or shell pipelines:
115
+
116
+ ```bash
117
+ start-ai-cli --all
118
+ ```
119
+
120
+ ### `start-ai-cli cannot start: no selected CLI tools are available`
121
+
122
+ Choose at least one installed CLI in the prompt, or run `start-ai-cli --all` to launch every available tool.
123
+
124
+ ### `start-ai-cli cancelled`
125
+
126
+ The interactive selector was closed with `q` or Esc. Run `start-ai-cli` again and press Enter after selecting at least one available CLI.
127
+
79
128
  ### Windows Terminal was not found
80
129
 
81
130
  Install Windows Terminal and make sure `wt.exe` is available in `PATH`.
@@ -1,9 +1,12 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
- import { spawn, spawnSync } from 'node:child_process';
4
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
5
6
  import { dirname, resolve, win32 as pathWin32 } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
+ import { fileURLToPath } from 'node:url';
8
+ import React, { useState } from 'react';
9
+ import { Box, Text, render, useApp, useInput } from 'ink';
7
10
 
8
11
  const COMMAND = 'start-ai-cli';
9
12
 
@@ -12,29 +15,37 @@ const HARD_REQUIREMENTS_BY_PLATFORM = {
12
15
  darwin: [{ command: 'osascript', label: 'AppleScript runner (osascript)' }]
13
16
  };
14
17
 
15
- const CLI_COMMANDS = [
16
- { command: 'codex', label: 'Codex CLI (codex)', title: 'Codex' },
17
- { command: 'claude', label: 'Claude Code CLI (claude)', title: 'Claude' },
18
- { command: 'agent', label: 'Cursor CLI (agent)', title: 'Cursor' }
19
- ];
20
-
18
+ const CLI_COMMANDS = [
19
+ { id: 'codex', command: 'codex', label: 'Codex CLI (codex)', title: 'Codex' },
20
+ { id: 'claude', command: 'claude', label: 'Claude Code CLI (claude)', title: 'Claude' },
21
+ { id: 'cursor', command: 'agent', label: 'Cursor CLI (agent)', title: 'Cursor' }
22
+ ];
23
+
21
24
  const DEFAULT_WINDOWS_SHELL_COMMAND = 'pwsh.exe';
25
+ const DEFAULT_ENABLED_CLI_IDS = CLI_COMMANDS.map(({ id }) => id);
26
+ const EMPTY_SELECTION_MESSAGE = 'Select at least one available CLI.';
22
27
 
23
28
  const HELP_TEXT = `Usage:
24
29
  ${COMMAND}
30
+ ${COMMAND} --all
25
31
  ${COMMAND} --help
26
32
  ${COMMAND} --version
27
33
 
28
- Opens terminal tabs/windows in the current directory:
34
+ Interactively choose which CLI tools to open in the current directory:
29
35
  - Codex: runs "codex"
30
36
  - Claude: runs "claude"
31
37
  - Cursor: runs "agent"
32
38
 
33
- Requirements:
34
- - Windows with Windows Terminal (wt.exe) and PowerShell, or macOS with Terminal.app
35
- - Codex CLI available as "codex"
36
- - Claude Code CLI available as "claude"
37
- - Cursor CLI available as "agent"
39
+ Options:
40
+ --all, --no-interactive Open every available CLI without prompting
41
+ --help, -h Show this help
42
+ --version, -v Show the package version
43
+
44
+ Requirements:
45
+ - Windows with Windows Terminal (wt.exe) and PowerShell, or macOS with Terminal.app
46
+ - Codex CLI available as "codex"
47
+ - Claude Code CLI available as "claude"
48
+ - Cursor CLI available as "agent"
38
49
  `;
39
50
 
40
51
  export function parseArgs(args) {
@@ -46,12 +57,14 @@ export function parseArgs(args) {
46
57
  return { action: 'version' };
47
58
  }
48
59
 
49
- const unknown = args.filter((arg) => arg.startsWith('-'));
60
+ const nonInteractive = args.includes('--all') || args.includes('--no-interactive');
61
+ const known = new Set(['--all', '--no-interactive']);
62
+ const unknown = args.filter((arg) => arg.startsWith('-') && !known.has(arg));
50
63
  if (unknown.length > 0) {
51
64
  return { action: 'error', message: `Unknown option: ${unknown.join(', ')}` };
52
65
  }
53
66
 
54
- return { action: 'open' };
67
+ return { action: 'open', interactive: !nonInteractive };
55
68
  }
56
69
 
57
70
  export function getPackageVersion() {
@@ -60,27 +73,350 @@ export function getPackageVersion() {
60
73
  return packageJson.version;
61
74
  }
62
75
 
63
- export function buildWtArgs({ cwd, tabs, shellCommand = DEFAULT_WINDOWS_SHELL_COMMAND }) {
64
- const result = [];
65
- for (let i = 0; i < tabs.length; i++) {
66
- if (i > 0) result.push(';');
67
- result.push(
68
- 'new-tab',
69
- '--title',
70
- tabs[i].title,
71
- '-d',
72
- cwd,
73
- shellCommand,
74
- '-NoExit',
75
- '-ExecutionPolicy',
76
- 'Bypass',
77
- '-Command',
78
- tabs[i].command
79
- );
76
+ export function getConfigPath({ homeDirectory = homedir() } = {}) {
77
+ return resolve(homeDirectory, '.start-ai-cli', 'config.json');
78
+ }
79
+
80
+ export function normalizeEnabledCliIds(enabledCliIds, cliCommands = CLI_COMMANDS) {
81
+ const validIds = new Set(cliCommands.map(({ id }) => id));
82
+ if (!Array.isArray(enabledCliIds)) {
83
+ return cliCommands.map(({ id }) => id);
84
+ }
85
+
86
+ const normalized = [];
87
+ for (const id of enabledCliIds) {
88
+ if (typeof id === 'string' && validIds.has(id) && !normalized.includes(id)) {
89
+ normalized.push(id);
90
+ }
91
+ }
92
+
93
+ return normalized;
94
+ }
95
+
96
+ export function readConfig({
97
+ configPath = getConfigPath(),
98
+ readFileFn = readFileSync
99
+ } = {}) {
100
+ try {
101
+ const config = JSON.parse(readFileFn(configPath, 'utf8'));
102
+ return {
103
+ enabledClis: normalizeEnabledCliIds(config.enabledClis)
104
+ };
105
+ } catch {
106
+ return {
107
+ enabledClis: [...DEFAULT_ENABLED_CLI_IDS]
108
+ };
109
+ }
110
+ }
111
+
112
+ export function writeConfig({
113
+ configPath = getConfigPath(),
114
+ enabledClis,
115
+ mkdirFn = mkdirSync,
116
+ writeFileFn = writeFileSync
117
+ } = {}) {
118
+ const config = {
119
+ enabledClis: normalizeEnabledCliIds(enabledClis)
120
+ };
121
+ mkdirFn(dirname(configPath), { recursive: true });
122
+ writeFileFn(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
123
+ return config;
124
+ }
125
+
126
+ export function inspectCliCommands({
127
+ env = process.env,
128
+ platform = process.platform,
129
+ commandExistsFn = commandExists,
130
+ cliCommands = CLI_COMMANDS
131
+ } = {}) {
132
+ return cliCommands.map((cli) => ({
133
+ ...cli,
134
+ available: commandExistsFn(cli.command, { env, platform })
135
+ }));
136
+ }
137
+
138
+ export function getAvailableTabsFromInspection(inspectedClis) {
139
+ return inspectedClis.filter(({ available }) => available);
140
+ }
141
+
142
+ export function getMissingLabelsFromInspection(inspectedClis) {
143
+ return inspectedClis
144
+ .filter(({ available }) => !available)
145
+ .map(({ label }) => label);
146
+ }
147
+
148
+ export function filterTabsByEnabledCliIds(tabs, enabledCliIds) {
149
+ const enabled = new Set(normalizeEnabledCliIds(enabledCliIds));
150
+ return tabs.filter(({ id }) => enabled.has(id));
151
+ }
152
+
153
+ export function isInteractiveTerminal({
154
+ input = process.stdin,
155
+ output = process.stdout
156
+ } = {}) {
157
+ return Boolean(input.isTTY && output.isTTY);
158
+ }
159
+
160
+ export class CliSelectionCancelledError extends Error {
161
+ constructor() {
162
+ super('CLI selection cancelled.');
163
+ this.name = 'CliSelectionCancelledError';
164
+ }
165
+ }
166
+
167
+ function getFirstAvailableCliIndex(inspectedClis) {
168
+ const index = inspectedClis.findIndex(({ available }) => available);
169
+ return index === -1 ? 0 : index;
170
+ }
171
+
172
+ function getAvailableCliIds(inspectedClis) {
173
+ return new Set(inspectedClis.filter(({ available }) => available).map(({ id }) => id));
174
+ }
175
+
176
+ function orderSelectedIds(selectedIds, inspectedClis) {
177
+ const selected = new Set(selectedIds);
178
+ return inspectedClis.filter(({ id }) => selected.has(id)).map(({ id }) => id);
179
+ }
180
+
181
+ export function createCliSelectionState({
182
+ inspectedClis,
183
+ defaultEnabledCliIds
184
+ }) {
185
+ const availableIds = getAvailableCliIds(inspectedClis);
186
+ return {
187
+ cursorIndex: getFirstAvailableCliIndex(inspectedClis),
188
+ selectedIds: normalizeEnabledCliIds(defaultEnabledCliIds, inspectedClis)
189
+ .filter((id) => availableIds.has(id)),
190
+ errorMessage: null
191
+ };
192
+ }
193
+
194
+ export function moveCliSelectionCursor({
195
+ state,
196
+ inspectedClis,
197
+ direction
198
+ }) {
199
+ if (inspectedClis.every(({ available }) => !available)) {
200
+ return state;
201
+ }
202
+
203
+ let cursorIndex = state.cursorIndex;
204
+ for (let i = 0; i < inspectedClis.length; i++) {
205
+ cursorIndex = (cursorIndex + direction + inspectedClis.length) % inspectedClis.length;
206
+ if (inspectedClis[cursorIndex].available) {
207
+ return {
208
+ ...state,
209
+ cursorIndex,
210
+ errorMessage: null
211
+ };
212
+ }
213
+ }
214
+
215
+ return state;
216
+ }
217
+
218
+ export function toggleCliSelectionAtCursor({
219
+ state,
220
+ inspectedClis
221
+ }) {
222
+ const cli = inspectedClis[state.cursorIndex];
223
+ if (!cli?.available) {
224
+ return state;
225
+ }
226
+
227
+ const selected = new Set(state.selectedIds);
228
+ if (selected.has(cli.id)) {
229
+ selected.delete(cli.id);
230
+ } else {
231
+ selected.add(cli.id);
232
+ }
233
+
234
+ return {
235
+ ...state,
236
+ selectedIds: orderSelectedIds(selected, inspectedClis),
237
+ errorMessage: null
238
+ };
239
+ }
240
+
241
+ export function confirmCliSelection({
242
+ state,
243
+ inspectedClis
244
+ }) {
245
+ const availableIds = getAvailableCliIds(inspectedClis);
246
+ const selectedIds = orderSelectedIds(
247
+ state.selectedIds.filter((id) => availableIds.has(id)),
248
+ inspectedClis
249
+ );
250
+
251
+ if (selectedIds.length === 0) {
252
+ return {
253
+ confirmed: false,
254
+ state: {
255
+ ...state,
256
+ selectedIds,
257
+ errorMessage: EMPTY_SELECTION_MESSAGE
258
+ }
259
+ };
80
260
  }
81
- return result;
261
+
262
+ return {
263
+ confirmed: true,
264
+ selectedIds
265
+ };
266
+ }
267
+
268
+ export function CliSelectionPrompt({
269
+ inspectedClis,
270
+ defaultEnabledCliIds,
271
+ onSubmit,
272
+ onCancel
273
+ }) {
274
+ const { exit } = useApp();
275
+ const [state, setState] = useState(() => createCliSelectionState({
276
+ inspectedClis,
277
+ defaultEnabledCliIds
278
+ }));
279
+
280
+ useInput((input, key) => {
281
+ if (key.upArrow) {
282
+ setState((current) => moveCliSelectionCursor({
283
+ state: current,
284
+ inspectedClis,
285
+ direction: -1
286
+ }));
287
+ return;
288
+ }
289
+
290
+ if (key.downArrow) {
291
+ setState((current) => moveCliSelectionCursor({
292
+ state: current,
293
+ inspectedClis,
294
+ direction: 1
295
+ }));
296
+ return;
297
+ }
298
+
299
+ if (input === ' ') {
300
+ setState((current) => toggleCliSelectionAtCursor({
301
+ state: current,
302
+ inspectedClis
303
+ }));
304
+ return;
305
+ }
306
+
307
+ if (key.return) {
308
+ const result = confirmCliSelection({
309
+ state,
310
+ inspectedClis
311
+ });
312
+
313
+ if (result.confirmed) {
314
+ onSubmit(result.selectedIds);
315
+ exit();
316
+ return;
317
+ }
318
+
319
+ setState(result.state);
320
+ return;
321
+ }
322
+
323
+ if (input === 'q' || key.escape) {
324
+ onCancel();
325
+ exit();
326
+ }
327
+ });
328
+
329
+ const selected = new Set(state.selectedIds);
330
+ return React.createElement(
331
+ Box,
332
+ { flexDirection: 'column' },
333
+ React.createElement(Text, { bold: true }, 'Select CLI tools to launch'),
334
+ ...inspectedClis.map((cli, index) => {
335
+ const focused = index === state.cursorIndex;
336
+ const marker = focused ? '>' : ' ';
337
+ const checked = selected.has(cli.id) ? '[x]' : '[ ]';
338
+ const availability = cli.available ? 'available' : 'not found in PATH';
339
+ const color = cli.available && focused ? 'cyan' : undefined;
340
+
341
+ return React.createElement(
342
+ Text,
343
+ {
344
+ key: cli.id,
345
+ color,
346
+ dimColor: !cli.available
347
+ },
348
+ `${marker} ${checked} ${cli.label} - ${availability}`
349
+ );
350
+ }),
351
+ state.errorMessage
352
+ ? React.createElement(Text, { color: 'red' }, state.errorMessage)
353
+ : null,
354
+ React.createElement(
355
+ Text,
356
+ { dimColor: true },
357
+ 'Use Up/Down to move, Space to toggle, Enter to open, q/Esc to cancel.'
358
+ )
359
+ );
360
+ }
361
+
362
+ export async function promptForEnabledCliIds({
363
+ input = process.stdin,
364
+ output = process.stdout,
365
+ inspectedClis,
366
+ defaultEnabledCliIds,
367
+ renderFn = render
368
+ }) {
369
+ return new Promise((resolve, reject) => {
370
+ let app;
371
+ let settled = false;
372
+ const settle = (callback, value) => {
373
+ if (settled) {
374
+ return;
375
+ }
376
+
377
+ settled = true;
378
+ callback(value);
379
+ app?.unmount();
380
+ };
381
+
382
+ app = renderFn(
383
+ React.createElement(CliSelectionPrompt, {
384
+ inspectedClis,
385
+ defaultEnabledCliIds,
386
+ onSubmit: (selectedIds) => settle(resolve, selectedIds),
387
+ onCancel: () => settle(reject, new CliSelectionCancelledError())
388
+ }),
389
+ {
390
+ stdin: input,
391
+ stdout: output,
392
+ stderr: output,
393
+ exitOnCtrlC: false
394
+ }
395
+ );
396
+ });
82
397
  }
83
398
 
399
+ export function buildWtArgs({ cwd, tabs, shellCommand = DEFAULT_WINDOWS_SHELL_COMMAND }) {
400
+ const result = [];
401
+ for (let i = 0; i < tabs.length; i++) {
402
+ if (i > 0) result.push(';');
403
+ result.push(
404
+ 'new-tab',
405
+ '--title',
406
+ tabs[i].title,
407
+ '-d',
408
+ cwd,
409
+ shellCommand,
410
+ '-NoExit',
411
+ '-ExecutionPolicy',
412
+ 'Bypass',
413
+ '-Command',
414
+ tabs[i].command
415
+ );
416
+ }
417
+ return result;
418
+ }
419
+
84
420
  function shellQuote(value) {
85
421
  return `'${String(value).replaceAll("'", "'\\''")}'`;
86
422
  }
@@ -108,7 +444,7 @@ export function buildMacTerminalArgs({ cwd, tabs }) {
108
444
  return ['-e', buildMacTerminalScript({ cwd, tabs })];
109
445
  }
110
446
 
111
- export function commandExists(command, { env = process.env, platform = process.platform } = {}) {
447
+ export function commandExists(command, { env = process.env, platform = process.platform } = {}) {
112
448
  const executable = platform === 'win32' ? 'where.exe' : 'sh';
113
449
  const args = platform === 'win32'
114
450
  ? [command]
@@ -119,82 +455,94 @@ export function commandExists(command, { env = process.env, platform = process.p
119
455
  stdio: 'ignore',
120
456
  windowsHide: true
121
457
  });
122
-
123
- return result.status === 0;
124
- }
125
-
126
- function getSystemWindowsPowerShellPath(env = process.env) {
127
- const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? env.windir ?? env.WINDIR;
128
- if (!systemRoot) {
129
- return null;
130
- }
131
-
132
- return pathWin32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
133
- }
134
-
135
- export function getWindowsShellCommand({
136
- env = process.env,
137
- platform = process.platform,
138
- commandExistsFn = commandExists,
139
- fileExistsFn = existsSync
140
- } = {}) {
141
- if (platform !== 'win32') {
142
- return null;
143
- }
144
-
145
- if (commandExistsFn('pwsh.exe', { env, platform })) {
146
- return 'pwsh.exe';
147
- }
148
-
149
- if (commandExistsFn('powershell.exe', { env, platform })) {
150
- return 'powershell.exe';
151
- }
152
-
153
- const systemPowerShellPath = getSystemWindowsPowerShellPath(env);
154
- if (systemPowerShellPath && fileExistsFn(systemPowerShellPath)) {
155
- return systemPowerShellPath;
156
- }
157
-
158
- return null;
159
- }
160
-
161
- export function getMissingRequirements({ platform = process.platform, env = process.env } = {}) {
162
- const requirements = HARD_REQUIREMENTS_BY_PLATFORM[platform];
163
- if (!requirements) {
164
- return ['Windows or macOS is required.'];
165
- }
166
-
167
- const missing = requirements
168
- .filter(({ command }) => !commandExists(command, { env, platform }))
169
- .map(({ label }) => `${label} was not found in PATH.`);
170
-
171
- if (platform === 'win32' && !getWindowsShellCommand({ env, platform })) {
172
- missing.push('PowerShell (pwsh.exe or powershell.exe) was not found.');
173
- }
174
-
175
- return missing;
176
- }
177
458
 
178
- export function getAvailableCliTabs({ env = process.env, platform = process.platform } = {}) {
179
- return CLI_COMMANDS.filter(({ command }) => commandExists(command, { env, platform }));
459
+ return result.status === 0;
460
+ }
461
+
462
+ function getSystemWindowsPowerShellPath(env = process.env) {
463
+ const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? env.windir ?? env.WINDIR;
464
+ if (!systemRoot) {
465
+ return null;
466
+ }
467
+
468
+ return pathWin32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
180
469
  }
181
470
 
182
- export function getMissingCliLabels({ env = process.env, platform = process.platform } = {}) {
471
+ export function getWindowsShellCommand({
472
+ env = process.env,
473
+ platform = process.platform,
474
+ commandExistsFn = commandExists,
475
+ fileExistsFn = existsSync
476
+ } = {}) {
477
+ if (platform !== 'win32') {
478
+ return null;
479
+ }
480
+
481
+ if (commandExistsFn('pwsh.exe', { env, platform })) {
482
+ return 'pwsh.exe';
483
+ }
484
+
485
+ if (commandExistsFn('powershell.exe', { env, platform })) {
486
+ return 'powershell.exe';
487
+ }
488
+
489
+ const systemPowerShellPath = getSystemWindowsPowerShellPath(env);
490
+ if (systemPowerShellPath && fileExistsFn(systemPowerShellPath)) {
491
+ return systemPowerShellPath;
492
+ }
493
+
494
+ return null;
495
+ }
496
+
497
+ export function getMissingRequirements({
498
+ platform = process.platform,
499
+ env = process.env,
500
+ commandExistsFn = commandExists
501
+ } = {}) {
502
+ const requirements = HARD_REQUIREMENTS_BY_PLATFORM[platform];
503
+ if (!requirements) {
504
+ return ['Windows or macOS is required.'];
505
+ }
506
+
507
+ const missing = requirements
508
+ .filter(({ command }) => !commandExistsFn(command, { env, platform }))
509
+ .map(({ label }) => `${label} was not found in PATH.`);
510
+
511
+ if (platform === 'win32' && !getWindowsShellCommand({ env, platform, commandExistsFn })) {
512
+ missing.push('PowerShell (pwsh.exe or powershell.exe) was not found.');
513
+ }
514
+
515
+ return missing;
516
+ }
517
+
518
+ export function getAvailableCliTabs({
519
+ env = process.env,
520
+ platform = process.platform,
521
+ commandExistsFn = commandExists
522
+ } = {}) {
523
+ return CLI_COMMANDS.filter(({ command }) => commandExistsFn(command, { env, platform }));
524
+ }
525
+
526
+ export function getMissingCliLabels({
527
+ env = process.env,
528
+ platform = process.platform,
529
+ commandExistsFn = commandExists
530
+ } = {}) {
183
531
  return CLI_COMMANDS
184
- .filter(({ command }) => !commandExists(command, { env, platform }))
532
+ .filter(({ command }) => !commandExistsFn(command, { env, platform }))
185
533
  .map(({ label }) => label);
186
534
  }
187
535
 
188
- export function launchTerminals({ cwd = process.cwd(), env = process.env, platform = process.platform, tabs = CLI_COMMANDS } = {}) {
189
- const executable = platform === 'win32' ? 'wt.exe' : 'osascript';
190
- const shellCommand = platform === 'win32' ? getWindowsShellCommand({ env, platform }) : undefined;
191
- if (platform === 'win32' && !shellCommand) {
192
- throw new Error('PowerShell (pwsh.exe or powershell.exe) was not found.');
193
- }
194
-
195
- const args = platform === 'win32'
196
- ? buildWtArgs({ cwd, tabs, shellCommand })
197
- : buildMacTerminalArgs({ cwd, tabs });
536
+ export function launchTerminals({ cwd = process.cwd(), env = process.env, platform = process.platform, tabs = CLI_COMMANDS } = {}) {
537
+ const executable = platform === 'win32' ? 'wt.exe' : 'osascript';
538
+ const shellCommand = platform === 'win32' ? getWindowsShellCommand({ env, platform }) : undefined;
539
+ if (platform === 'win32' && !shellCommand) {
540
+ throw new Error('PowerShell (pwsh.exe or powershell.exe) was not found.');
541
+ }
542
+
543
+ const args = platform === 'win32'
544
+ ? buildWtArgs({ cwd, tabs, shellCommand })
545
+ : buildMacTerminalArgs({ cwd, tabs });
198
546
 
199
547
  const child = spawn(executable, args, {
200
548
  cwd,
@@ -207,7 +555,7 @@ export function launchTerminals({ cwd = process.cwd(), env = process.env, platfo
207
555
  child.unref();
208
556
  }
209
557
 
210
- export function main(args = process.argv.slice(2), options = {}) {
558
+ export async function main(args = process.argv.slice(2), options = {}) {
211
559
  const parsed = parseArgs(args);
212
560
 
213
561
  if (parsed.action === 'help') {
@@ -235,19 +583,63 @@ export function main(args = process.argv.slice(2), options = {}) {
235
583
  return 1;
236
584
  }
237
585
 
238
- const missingClis = getMissingCliLabels(options);
586
+ const inspectedClis = inspectCliCommands(options);
587
+ const missingClis = getMissingLabelsFromInspection(inspectedClis);
239
588
  for (const label of missingClis) {
240
589
  console.warn(`Warning: ${label} was not found in PATH, skipping.`);
241
590
  }
242
591
 
243
- const availableTabs = getAvailableCliTabs(options);
592
+ const availableTabs = getAvailableTabsFromInspection(inspectedClis);
244
593
  if (availableTabs.length === 0) {
245
594
  console.error(`${COMMAND} cannot start: no CLI tools found in PATH.`);
246
595
  return 1;
247
596
  }
248
597
 
249
- launchTerminals({ ...options, tabs: availableTabs });
250
- const launched = availableTabs.map(({ title }) => title).join(', ');
598
+ let tabsToLaunch = availableTabs;
599
+ if (parsed.interactive) {
600
+ const input = options.input ?? process.stdin;
601
+ const output = options.output ?? process.stdout;
602
+ if (!isInteractiveTerminal({ input, output })) {
603
+ console.error(`${COMMAND} cannot prompt in a non-interactive terminal.`);
604
+ console.error(`Run "${COMMAND} --all" to open every available CLI without prompting.`);
605
+ return 1;
606
+ }
607
+
608
+ const config = readConfig(options);
609
+ let enabledClis;
610
+ try {
611
+ enabledClis = await promptForEnabledCliIds({
612
+ input,
613
+ output,
614
+ inspectedClis,
615
+ defaultEnabledCliIds: config.enabledClis,
616
+ renderFn: options.renderFn
617
+ });
618
+ } catch (error) {
619
+ if (error instanceof CliSelectionCancelledError) {
620
+ console.error(`${COMMAND} cancelled.`);
621
+ return 1;
622
+ }
623
+
624
+ throw error;
625
+ }
626
+
627
+ try {
628
+ writeConfig({ ...options, enabledClis });
629
+ } catch (error) {
630
+ console.warn(`Warning: could not save CLI selection: ${error.message}`);
631
+ }
632
+
633
+ tabsToLaunch = filterTabsByEnabledCliIds(availableTabs, enabledClis);
634
+ if (tabsToLaunch.length === 0) {
635
+ console.error(`${COMMAND} cannot start: no selected CLI tools are available.`);
636
+ return 1;
637
+ }
638
+ }
639
+
640
+ const launchTerminalsFn = options.launchTerminalsFn ?? launchTerminals;
641
+ launchTerminalsFn({ ...options, tabs: tabsToLaunch });
642
+ const launched = tabsToLaunch.map(({ title }) => title).join(', ');
251
643
  const terminalName = (options.platform ?? process.platform) === 'win32' ? 'Windows Terminal' : 'Terminal.app';
252
644
  console.log(`Opened ${launched} in ${terminalName}.`);
253
645
  return 0;
@@ -265,5 +657,12 @@ export function isEntrypoint(argvPath = process.argv[1], moduleUrl = import.meta
265
657
  }
266
658
 
267
659
  if (isEntrypoint()) {
268
- process.exitCode = main();
660
+ main()
661
+ .then((exitCode) => {
662
+ process.exitCode = exitCode;
663
+ })
664
+ .catch((error) => {
665
+ console.error(error.message);
666
+ process.exitCode = 1;
667
+ });
269
668
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
- "name": "start-ai-cli",
3
- "version": "0.1.1",
4
- "description": "Launch Codex CLI, Claude Code, and Cursor CLI together from one project directory in Windows Terminal or macOS Terminal.",
5
- "type": "module",
6
- "main": "bin/start-ai-cli.js",
7
- "exports": "./bin/start-ai-cli.js",
8
- "bin": {
9
- "start-ai-cli": "bin/start-ai-cli.js"
10
- },
2
+ "name": "start-ai-cli",
3
+ "version": "0.2.0",
4
+ "description": "Launch selected Codex CLI, Claude Code, and Cursor CLI tools from one project directory in Windows Terminal or macOS Terminal.",
5
+ "type": "module",
6
+ "main": "bin/start-ai-cli.js",
7
+ "exports": "./bin/start-ai-cli.js",
8
+ "bin": {
9
+ "start-ai-cli": "bin/start-ai-cli.js"
10
+ },
11
11
  "scripts": {
12
12
  "test": "node --test",
13
13
  "pack:dry-run": "npm pack --dry-run",
@@ -18,37 +18,37 @@
18
18
  "README.md",
19
19
  "LICENSE"
20
20
  ],
21
- "keywords": [
22
- "start-ai-cli",
23
- "open-ai-cli",
24
- "ai-cli",
25
- "ai-agent",
26
- "ai-coding",
27
- "ai-coding-agent",
28
- "coding-agent",
29
- "codex",
30
- "codex-cli",
31
- "openai-codex",
32
- "claude",
33
- "claude-code",
34
- "claude-code-cli",
35
- "cursor",
36
- "cursor-cli",
37
- "cursor-agent",
38
- "cli",
39
- "cli-launcher",
40
- "developer-tools",
41
- "terminal",
42
- "terminal-tabs",
43
- "windows-terminal",
44
- "macos",
45
- "macos-terminal",
46
- "productivity"
47
- ],
48
- "author": {
49
- "name": "guoxiao0521",
50
- "url": "https://github.com/guoxiao0521"
51
- },
21
+ "keywords": [
22
+ "start-ai-cli",
23
+ "open-ai-cli",
24
+ "ai-cli",
25
+ "ai-agent",
26
+ "ai-coding",
27
+ "ai-coding-agent",
28
+ "coding-agent",
29
+ "codex",
30
+ "codex-cli",
31
+ "openai-codex",
32
+ "claude",
33
+ "claude-code",
34
+ "claude-code-cli",
35
+ "cursor",
36
+ "cursor-cli",
37
+ "cursor-agent",
38
+ "cli",
39
+ "cli-launcher",
40
+ "developer-tools",
41
+ "terminal",
42
+ "terminal-tabs",
43
+ "windows-terminal",
44
+ "macos",
45
+ "macos-terminal",
46
+ "productivity"
47
+ ],
48
+ "author": {
49
+ "name": "guoxiao0521",
50
+ "url": "https://github.com/guoxiao0521"
51
+ },
52
52
  "license": "MIT",
53
53
  "repository": {
54
54
  "type": "git",
@@ -62,8 +62,15 @@
62
62
  "win32",
63
63
  "darwin"
64
64
  ],
65
+ "dependencies": {
66
+ "ink": "^7.0.6",
67
+ "react": "^19.2.7"
68
+ },
69
+ "devDependencies": {
70
+ "ink-testing-library": "^4.0.0"
71
+ },
65
72
  "engines": {
66
- "node": ">=18"
73
+ "node": ">=22"
67
74
  },
68
75
  "publishConfig": {
69
76
  "registry": "https://registry.npmjs.org/"