start-ai-cli 0.1.0 → 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 (4) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +116 -19
  3. package/bin/start-ai-cli.js +633 -135
  4. package/package.json +78 -45
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,49 +1,144 @@
1
- # start-ai-cli
1
+ # start-ai-cli - AI coding CLI launcher
2
2
 
3
- Open Codex CLI, Claude Code, and Cursor CLI from the current directory in one Windows Terminal window.
3
+ [![npm version](https://img.shields.io/npm/v/start-ai-cli.svg)](https://www.npmjs.com/package/start-ai-cli)
4
+ [![npm downloads](https://img.shields.io/npm/dm/start-ai-cli.svg)](https://www.npmjs.com/package/start-ai-cli)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
4
6
 
5
- ## Requirements
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
+
9
+ Use it when you want one command to choose and start your AI coding agents:
10
+
11
+ ```bash
12
+ npx start-ai-cli
13
+ ```
14
+
15
+ ## Features
6
16
 
7
- - Windows
8
- - Node.js 18 or newer
9
- - Windows Terminal available as `wt.exe`
10
- - Codex CLI available as `codex`
11
- - Claude Code CLI available as `claude`
12
- - Cursor CLI available as `agent`
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.
19
+ - Opens each available tool in its own Windows Terminal tab or macOS Terminal window.
20
+ - Skips missing CLIs instead of failing when at least one supported tool is available.
21
+ - Works as a global npm command or one-off `npx` command.
13
22
 
14
23
  ## Install
15
24
 
25
+ Run without installing:
26
+
16
27
  ```bash
17
- npm install -g start-ai-cli
28
+ npx start-ai-cli
18
29
  ```
19
30
 
20
- For local development from this directory:
31
+ Install globally:
21
32
 
22
33
  ```bash
23
- npm link
34
+ npm install -g start-ai-cli
24
35
  ```
25
36
 
26
- ## Usage
27
-
28
- Run the command from the project directory you want both tools to use:
37
+ Then run it from the project directory you want the AI tools to use:
29
38
 
30
39
  ```bash
31
40
  start-ai-cli
32
41
  ```
33
42
 
34
- It opens Windows Terminal with three tabs:
43
+ ## Supported Tools
44
+
45
+ | Tool | Required command | Terminal title |
46
+ | --- | --- | --- |
47
+ | Codex CLI | `codex` | `Codex` |
48
+ | Claude Code | `claude` | `Claude` |
49
+ | Cursor CLI | `agent` | `Cursor` |
50
+
51
+ On Windows, `start-ai-cli` uses Windows Terminal (`wt.exe`) with PowerShell. On macOS, it uses Terminal.app through `osascript`.
35
52
 
36
- - `Codex`, running `codex`
37
- - `Claude`, running `claude`
38
- - `Cursor`, running `agent`
53
+ ## Requirements
54
+
55
+ - Windows or macOS
56
+ - Node.js 22 or newer
57
+ - Windows Terminal available as `wt.exe` on Windows
58
+ - PowerShell 7 available as `pwsh.exe`, or Windows PowerShell, on Windows
59
+ - Terminal.app and `osascript` available on macOS
60
+ - At least one supported AI coding CLI available in `PATH`
39
61
 
40
62
  ## Options
41
63
 
42
64
  ```bash
65
+ start-ai-cli
66
+ start-ai-cli --all
67
+ start-ai-cli --no-interactive
43
68
  start-ai-cli --help
44
69
  start-ai-cli --version
45
70
  ```
46
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
+
94
+ ## Troubleshooting
95
+
96
+ ### `start-ai-cli cannot start: no CLI tools found in PATH`
97
+
98
+ Install or expose at least one supported command in your shell:
99
+
100
+ - `codex` for Codex CLI
101
+ - `claude` for Claude Code
102
+ - `agent` for Cursor CLI
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
+
128
+ ### Windows Terminal was not found
129
+
130
+ Install Windows Terminal and make sure `wt.exe` is available in `PATH`.
131
+
132
+ ### PowerShell was not found
133
+
134
+ Install PowerShell 7 (`pwsh.exe`) or make sure Windows PowerShell (`powershell.exe`) is available.
135
+
136
+ ## Package Links
137
+
138
+ - npm package: https://www.npmjs.com/package/start-ai-cli
139
+ - GitHub repository: https://github.com/guoxiao0521/open-ai-cli
140
+ - Issues: https://github.com/guoxiao0521/open-ai-cli/issues
141
+
47
142
  ## Development
48
143
 
49
144
  ```bash
@@ -54,6 +149,8 @@ npm run publish:dry-run
54
149
 
55
150
  ## Publish
56
151
 
152
+ `README.md` and npm metadata changes appear on npm after publishing a new package version.
153
+
57
154
  ```bash
58
155
  npm login --registry=https://registry.npmjs.org/
59
156
  npm publish
@@ -1,170 +1,668 @@
1
- #!/usr/bin/env node
2
-
1
+ #!/usr/bin/env node
2
+
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
- import { readFileSync, realpathSync } from 'node:fs';
5
- import { dirname, resolve } from 'node:path';
4
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, resolve, win32 as pathWin32 } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
8
+ import React, { useState } from 'react';
9
+ import { Box, Text, render, useApp, useInput } from 'ink';
10
+
11
+ const COMMAND = 'start-ai-cli';
12
+
13
+ const HARD_REQUIREMENTS_BY_PLATFORM = {
14
+ win32: [{ command: 'wt.exe', label: 'Windows Terminal (wt.exe)' }],
15
+ darwin: [{ command: 'osascript', label: 'AppleScript runner (osascript)' }]
16
+ };
17
+
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
+
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.';
27
+
28
+ const HELP_TEXT = `Usage:
29
+ ${COMMAND}
30
+ ${COMMAND} --all
31
+ ${COMMAND} --help
32
+ ${COMMAND} --version
33
+
34
+ Interactively choose which CLI tools to open in the current directory:
35
+ - Codex: runs "codex"
36
+ - Claude: runs "claude"
37
+ - Cursor: runs "agent"
38
+
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"
49
+ `;
50
+
51
+ export function parseArgs(args) {
52
+ if (args.includes('--help') || args.includes('-h')) {
53
+ return { action: 'help' };
54
+ }
55
+
56
+ if (args.includes('--version') || args.includes('-v')) {
57
+ return { action: 'version' };
58
+ }
59
+
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));
63
+ if (unknown.length > 0) {
64
+ return { action: 'error', message: `Unknown option: ${unknown.join(', ')}` };
65
+ }
66
+
67
+ return { action: 'open', interactive: !nonInteractive };
68
+ }
69
+
70
+ export function getPackageVersion() {
71
+ const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
72
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
73
+ return packageJson.version;
74
+ }
75
+
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
+ }
7
152
 
8
- const COMMAND = 'start-ai-cli';
9
-
10
- const REQUIRED_COMMANDS = [
11
- { command: 'wt.exe', label: 'Windows Terminal (wt.exe)' },
12
- { command: 'codex', label: 'Codex CLI (codex)' },
13
- { command: 'claude', label: 'Claude Code CLI (claude)' },
14
- { command: 'agent', label: 'Cursor CLI (agent)' }
15
- ];
16
-
17
- const HELP_TEXT = `Usage:
18
- ${COMMAND}
19
- ${COMMAND} --help
20
- ${COMMAND} --version
21
-
22
- Opens Windows Terminal with three tabs in the current directory:
23
- - Codex: runs "codex"
24
- - Claude: runs "claude"
25
- - Cursor: runs "agent"
26
-
27
- Requirements:
28
- - Windows
29
- - Windows Terminal (wt.exe)
30
- - Codex CLI available as "codex"
31
- - Claude Code CLI available as "claude"
32
- - Cursor CLI available as "agent"
33
- `;
34
-
35
- export function parseArgs(args) {
36
- if (args.includes('--help') || args.includes('-h')) {
37
- return { action: 'help' };
38
- }
153
+ export function isInteractiveTerminal({
154
+ input = process.stdin,
155
+ output = process.stdout
156
+ } = {}) {
157
+ return Boolean(input.isTTY && output.isTTY);
158
+ }
39
159
 
40
- if (args.includes('--version') || args.includes('-v')) {
41
- return { action: 'version' };
160
+ export class CliSelectionCancelledError extends Error {
161
+ constructor() {
162
+ super('CLI selection cancelled.');
163
+ this.name = 'CliSelectionCancelledError';
42
164
  }
165
+ }
43
166
 
44
- const unknown = args.filter((arg) => arg.startsWith('-'));
45
- if (unknown.length > 0) {
46
- return { action: 'error', message: `Unknown option: ${unknown.join(', ')}` };
47
- }
167
+ function getFirstAvailableCliIndex(inspectedClis) {
168
+ const index = inspectedClis.findIndex(({ available }) => available);
169
+ return index === -1 ? 0 : index;
170
+ }
48
171
 
49
- return { action: 'open' };
172
+ function getAvailableCliIds(inspectedClis) {
173
+ return new Set(inspectedClis.filter(({ available }) => available).map(({ id }) => id));
50
174
  }
51
175
 
52
- export function getPackageVersion() {
53
- const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
54
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
55
- return packageJson.version;
176
+ function orderSelectedIds(selectedIds, inspectedClis) {
177
+ const selected = new Set(selectedIds);
178
+ return inspectedClis.filter(({ id }) => selected.has(id)).map(({ id }) => id);
56
179
  }
57
180
 
58
- export function buildWtArgs({ cwd, codexCommand = 'codex', claudeCommand = 'claude', cursorCommand = 'agent' }) {
59
- return [
60
- 'new-tab',
61
- '--title',
62
- 'Codex',
63
- '-d',
64
- cwd,
65
- 'powershell.exe',
66
- '-NoExit',
67
- '-Command',
68
- codexCommand,
69
- ';',
70
- 'new-tab',
71
- '--title',
72
- 'Claude',
73
- '-d',
74
- cwd,
75
- 'powershell.exe',
76
- '-NoExit',
77
- '-Command',
78
- claudeCommand,
79
- ';',
80
- 'new-tab',
81
- '--title',
82
- 'Cursor',
83
- '-d',
84
- cwd,
85
- 'powershell.exe',
86
- '-NoExit',
87
- '-Command',
88
- cursorCommand
89
- ];
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
+ };
90
192
  }
91
193
 
92
- export function commandExists(command, { env = process.env } = {}) {
93
- const result = spawnSync('where.exe', [command], {
94
- env,
95
- stdio: 'ignore',
96
- windowsHide: true
97
- });
194
+ export function moveCliSelectionCursor({
195
+ state,
196
+ inspectedClis,
197
+ direction
198
+ }) {
199
+ if (inspectedClis.every(({ available }) => !available)) {
200
+ return state;
201
+ }
98
202
 
99
- return result.status === 0;
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;
100
216
  }
101
217
 
102
- export function getMissingRequirements({ platform = process.platform, env = process.env } = {}) {
103
- if (platform !== 'win32') {
104
- return ['Windows is required.'];
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);
105
232
  }
106
233
 
107
- return REQUIRED_COMMANDS
108
- .filter(({ command }) => !commandExists(command, { env }))
109
- .map(({ label }) => `${label} was not found in PATH.`);
234
+ return {
235
+ ...state,
236
+ selectedIds: orderSelectedIds(selected, inspectedClis),
237
+ errorMessage: null
238
+ };
110
239
  }
111
240
 
112
- export function launchTerminals({ cwd = process.cwd(), env = process.env } = {}) {
113
- const child = spawn('wt.exe', buildWtArgs({ cwd }), {
114
- cwd,
115
- env,
116
- detached: true,
117
- stdio: 'ignore',
118
- windowsHide: false
119
- });
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
+ };
260
+ }
120
261
 
121
- child.unref();
262
+ return {
263
+ confirmed: true,
264
+ selectedIds
265
+ };
122
266
  }
123
267
 
124
- export function main(args = process.argv.slice(2), options = {}) {
125
- const parsed = parseArgs(args);
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
+ }));
126
279
 
127
- if (parsed.action === 'help') {
128
- console.log(HELP_TEXT.trimEnd());
129
- return 0;
130
- }
280
+ useInput((input, key) => {
281
+ if (key.upArrow) {
282
+ setState((current) => moveCliSelectionCursor({
283
+ state: current,
284
+ inspectedClis,
285
+ direction: -1
286
+ }));
287
+ return;
288
+ }
131
289
 
132
- if (parsed.action === 'version') {
133
- console.log(getPackageVersion());
134
- return 0;
135
- }
290
+ if (key.downArrow) {
291
+ setState((current) => moveCliSelectionCursor({
292
+ state: current,
293
+ inspectedClis,
294
+ direction: 1
295
+ }));
296
+ return;
297
+ }
136
298
 
137
- if (parsed.action === 'error') {
138
- console.error(parsed.message);
139
- console.error(`Run "${COMMAND} --help" for usage.`);
140
- return 1;
141
- }
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
+ });
142
312
 
143
- const missing = getMissingRequirements(options);
144
- if (missing.length > 0) {
145
- console.error(`${COMMAND} cannot start:`);
146
- for (const message of missing) {
147
- console.error(`- ${message}`);
313
+ if (result.confirmed) {
314
+ onSubmit(result.selectedIds);
315
+ exit();
316
+ return;
317
+ }
318
+
319
+ setState(result.state);
320
+ return;
148
321
  }
149
- return 1;
150
- }
151
322
 
152
- launchTerminals(options);
153
- console.log('Opened Codex CLI, Claude Code, and Cursor CLI in Windows Terminal.');
154
- return 0;
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
+ );
155
360
  }
156
361
 
157
- export function isEntrypoint(argvPath = process.argv[1], moduleUrl = import.meta.url) {
158
- if (!argvPath) {
159
- return false;
160
- }
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
+ }
161
376
 
162
- const argvRealPath = realpathSync(resolve(argvPath));
163
- const moduleRealPath = realpathSync(fileURLToPath(moduleUrl));
377
+ settled = true;
378
+ callback(value);
379
+ app?.unmount();
380
+ };
164
381
 
165
- return argvRealPath === moduleRealPath;
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
+ });
166
397
  }
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
+
420
+ function shellQuote(value) {
421
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
422
+ }
423
+
424
+ function appleScriptQuote(value) {
425
+ return JSON.stringify(String(value));
426
+ }
427
+
428
+ export function buildMacTerminalScript({ cwd, tabs }) {
429
+ const commands = tabs.map(({ command }) => `cd ${shellQuote(cwd)} && ${command}`);
430
+ const lines = [
431
+ 'tell application "Terminal"',
432
+ ' activate'
433
+ ];
434
+
435
+ for (const command of commands) {
436
+ lines.push(` do script ${appleScriptQuote(command)}`);
437
+ }
438
+
439
+ lines.push('end tell');
440
+ return lines.join('\n');
441
+ }
442
+
443
+ export function buildMacTerminalArgs({ cwd, tabs }) {
444
+ return ['-e', buildMacTerminalScript({ cwd, tabs })];
445
+ }
446
+
447
+ export function commandExists(command, { env = process.env, platform = process.platform } = {}) {
448
+ const executable = platform === 'win32' ? 'where.exe' : 'sh';
449
+ const args = platform === 'win32'
450
+ ? [command]
451
+ : ['-lc', `command -v ${shellQuote(command)}`];
452
+
453
+ const result = spawnSync(executable, args, {
454
+ env,
455
+ stdio: 'ignore',
456
+ windowsHide: true
457
+ });
458
+
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');
469
+ }
470
+
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
+ } = {}) {
531
+ return CLI_COMMANDS
532
+ .filter(({ command }) => !commandExistsFn(command, { env, platform }))
533
+ .map(({ label }) => label);
534
+ }
535
+
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 });
546
+
547
+ const child = spawn(executable, args, {
548
+ cwd,
549
+ env,
550
+ detached: true,
551
+ stdio: 'ignore',
552
+ windowsHide: false
553
+ });
554
+
555
+ child.unref();
556
+ }
557
+
558
+ export async function main(args = process.argv.slice(2), options = {}) {
559
+ const parsed = parseArgs(args);
560
+
561
+ if (parsed.action === 'help') {
562
+ console.log(HELP_TEXT.trimEnd());
563
+ return 0;
564
+ }
565
+
566
+ if (parsed.action === 'version') {
567
+ console.log(getPackageVersion());
568
+ return 0;
569
+ }
570
+
571
+ if (parsed.action === 'error') {
572
+ console.error(parsed.message);
573
+ console.error(`Run "${COMMAND} --help" for usage.`);
574
+ return 1;
575
+ }
576
+
577
+ const missing = getMissingRequirements(options);
578
+ if (missing.length > 0) {
579
+ console.error(`${COMMAND} cannot start:`);
580
+ for (const message of missing) {
581
+ console.error(`- ${message}`);
582
+ }
583
+ return 1;
584
+ }
585
+
586
+ const inspectedClis = inspectCliCommands(options);
587
+ const missingClis = getMissingLabelsFromInspection(inspectedClis);
588
+ for (const label of missingClis) {
589
+ console.warn(`Warning: ${label} was not found in PATH, skipping.`);
590
+ }
591
+
592
+ const availableTabs = getAvailableTabsFromInspection(inspectedClis);
593
+ if (availableTabs.length === 0) {
594
+ console.error(`${COMMAND} cannot start: no CLI tools found in PATH.`);
595
+ return 1;
596
+ }
597
+
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
+ }
167
623
 
168
- if (isEntrypoint()) {
169
- process.exitCode = main();
170
- }
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(', ');
643
+ const terminalName = (options.platform ?? process.platform) === 'win32' ? 'Windows Terminal' : 'Terminal.app';
644
+ console.log(`Opened ${launched} in ${terminalName}.`);
645
+ return 0;
646
+ }
647
+
648
+ export function isEntrypoint(argvPath = process.argv[1], moduleUrl = import.meta.url) {
649
+ if (!argvPath) {
650
+ return false;
651
+ }
652
+
653
+ const argvRealPath = realpathSync(resolve(argvPath));
654
+ const moduleRealPath = realpathSync(fileURLToPath(moduleUrl));
655
+
656
+ return argvRealPath === moduleRealPath;
657
+ }
658
+
659
+ if (isEntrypoint()) {
660
+ main()
661
+ .then((exitCode) => {
662
+ process.exitCode = exitCode;
663
+ })
664
+ .catch((error) => {
665
+ console.error(error.message);
666
+ process.exitCode = 1;
667
+ });
668
+ }
package/package.json CHANGED
@@ -1,45 +1,78 @@
1
- {
2
- "name": "start-ai-cli",
3
- "version": "0.1.0",
4
- "description": "Open Codex CLI, Claude Code, and Cursor CLI in Windows Terminal tabs from the current directory.",
5
- "type": "module",
6
- "bin": {
7
- "start-ai-cli": "bin/start-ai-cli.js"
8
- },
9
- "scripts": {
10
- "test": "node --test",
11
- "pack:dry-run": "npm pack --dry-run",
12
- "publish:dry-run": "npm publish --dry-run"
13
- },
14
- "files": [
15
- "bin/",
16
- "README.md",
17
- "LICENSE"
18
- ],
19
- "keywords": [
20
- "codex",
21
- "claude",
22
- "cursor",
23
- "cli",
24
- "terminal",
25
- "windows-terminal"
26
- ],
27
- "license": "MIT",
28
- "repository": {
29
- "type": "git",
30
- "url": "git+https://github.com/guoxiao0521/open-ai-cli.git"
31
- },
32
- "bugs": {
33
- "url": "https://github.com/guoxiao0521/open-ai-cli/issues"
34
- },
35
- "homepage": "https://github.com/guoxiao0521/open-ai-cli#readme",
36
- "os": [
37
- "win32"
38
- ],
39
- "engines": {
40
- "node": ">=18"
41
- },
42
- "publishConfig": {
43
- "registry": "https://registry.npmjs.org/"
44
- }
45
- }
1
+ {
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
+ "scripts": {
12
+ "test": "node --test",
13
+ "pack:dry-run": "npm pack --dry-run",
14
+ "publish:dry-run": "npm publish --dry-run"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "README.md",
19
+ "LICENSE"
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
+ },
52
+ "license": "MIT",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/guoxiao0521/open-ai-cli.git"
56
+ },
57
+ "bugs": {
58
+ "url": "https://github.com/guoxiao0521/open-ai-cli/issues"
59
+ },
60
+ "homepage": "https://github.com/guoxiao0521/open-ai-cli#readme",
61
+ "os": [
62
+ "win32",
63
+ "darwin"
64
+ ],
65
+ "dependencies": {
66
+ "ink": "^7.0.6",
67
+ "react": "^19.2.7"
68
+ },
69
+ "devDependencies": {
70
+ "ink-testing-library": "^4.0.0"
71
+ },
72
+ "engines": {
73
+ "node": ">=22"
74
+ },
75
+ "publishConfig": {
76
+ "registry": "https://registry.npmjs.org/"
77
+ }
78
+ }