@mrpatronz/nexusflow 0.2.11 โ†’ 0.2.13

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 (111) hide show
  1. package/dist/commands/add-repo.d.ts.map +1 -1
  2. package/dist/commands/add-repo.js +24 -14
  3. package/dist/commands/add-repo.js.map +1 -1
  4. package/dist/commands/commit.js +1 -1
  5. package/dist/commands/commit.js.map +1 -1
  6. package/dist/commands/create.d.ts.map +1 -1
  7. package/dist/commands/create.js +9 -2
  8. package/dist/commands/create.js.map +1 -1
  9. package/dist/commands/diff.js +1 -1
  10. package/dist/commands/diff.js.map +1 -1
  11. package/dist/commands/doctor.js +1 -1
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/handoff.js +1 -1
  14. package/dist/commands/handoff.js.map +1 -1
  15. package/dist/commands/logs.js +1 -1
  16. package/dist/commands/logs.js.map +1 -1
  17. package/dist/commands/open.d.ts.map +1 -1
  18. package/dist/commands/open.js +21 -9
  19. package/dist/commands/open.js.map +1 -1
  20. package/dist/commands/pack.js +1 -1
  21. package/dist/commands/pack.js.map +1 -1
  22. package/dist/commands/refresh.js +13 -8
  23. package/dist/commands/refresh.js.map +1 -1
  24. package/dist/commands/remove.d.ts.map +1 -1
  25. package/dist/commands/remove.js +12 -7
  26. package/dist/commands/remove.js.map +1 -1
  27. package/dist/commands/start.js +1 -1
  28. package/dist/commands/start.js.map +1 -1
  29. package/dist/commands/status.js +1 -1
  30. package/dist/commands/status.js.map +1 -1
  31. package/dist/commands/stop.js +1 -1
  32. package/dist/commands/stop.js.map +1 -1
  33. package/dist/commands/sync.js +13 -8
  34. package/dist/commands/sync.js.map +1 -1
  35. package/dist/core/workspace.d.ts +7 -0
  36. package/dist/core/workspace.d.ts.map +1 -1
  37. package/dist/core/workspace.js +45 -1
  38. package/dist/core/workspace.js.map +1 -1
  39. package/dist/generators/antigravity.d.ts +15 -0
  40. package/dist/generators/antigravity.d.ts.map +1 -0
  41. package/dist/generators/antigravity.js +27 -0
  42. package/dist/generators/antigravity.js.map +1 -0
  43. package/dist/generators/index.d.ts +1 -0
  44. package/dist/generators/index.d.ts.map +1 -1
  45. package/dist/generators/index.js +17 -1
  46. package/dist/generators/index.js.map +1 -1
  47. package/dist/generators/skills-generator.d.ts.map +1 -1
  48. package/dist/generators/skills-generator.js +6 -1
  49. package/dist/generators/skills-generator.js.map +1 -1
  50. package/dist/gui/assets/index-CFWwqFux.js +23 -0
  51. package/dist/gui/assets/index-Dvc9QMvl.css +2 -0
  52. package/dist/gui/index.html +2 -2
  53. package/dist/orchestration/runner.d.ts +5 -6
  54. package/dist/orchestration/runner.d.ts.map +1 -1
  55. package/dist/orchestration/runner.js +105 -102
  56. package/dist/orchestration/runner.js.map +1 -1
  57. package/dist/server.d.ts.map +1 -1
  58. package/dist/server.js +57 -2
  59. package/dist/server.js.map +1 -1
  60. package/dist/utils/detect-ai.d.ts.map +1 -1
  61. package/dist/utils/detect-ai.js +4 -1
  62. package/dist/utils/detect-ai.js.map +1 -1
  63. package/dist/utils/detect-editors.d.ts.map +1 -1
  64. package/dist/utils/detect-editors.js +5 -2
  65. package/dist/utils/detect-editors.js.map +1 -1
  66. package/dist/utils/detect-editors.test.js +7 -6
  67. package/dist/utils/detect-editors.test.js.map +1 -1
  68. package/dist/utils/prompts.d.ts +1 -0
  69. package/dist/utils/prompts.d.ts.map +1 -1
  70. package/dist/utils/prompts.js +77 -15
  71. package/dist/utils/prompts.js.map +1 -1
  72. package/dist/utils/update-check.d.ts.map +1 -1
  73. package/dist/utils/update-check.js +16 -4
  74. package/dist/utils/update-check.js.map +1 -1
  75. package/extension/package.json +9 -0
  76. package/extension/src/extension.ts +37 -1
  77. package/gui/eslint.config.js +4 -0
  78. package/gui/src/App.tsx +375 -151
  79. package/gui/src/features/changes/ChangesViewer.tsx +246 -70
  80. package/gui/src/features/services/ServiceConsole.tsx +162 -71
  81. package/gui/src/features/workspace/WorkspaceList.tsx +184 -32
  82. package/gui/src/index.css +95 -2
  83. package/package.json +3 -2
  84. package/src/commands/add-repo.ts +30 -14
  85. package/src/commands/commit.ts +1 -1
  86. package/src/commands/create.ts +9 -2
  87. package/src/commands/diff.ts +1 -1
  88. package/src/commands/doctor.ts +1 -1
  89. package/src/commands/handoff.ts +1 -1
  90. package/src/commands/logs.ts +1 -1
  91. package/src/commands/open.ts +24 -9
  92. package/src/commands/pack.ts +1 -1
  93. package/src/commands/refresh.ts +16 -8
  94. package/src/commands/remove.ts +15 -7
  95. package/src/commands/start.ts +1 -1
  96. package/src/commands/status.ts +1 -1
  97. package/src/commands/stop.ts +1 -1
  98. package/src/commands/sync.ts +16 -8
  99. package/src/core/workspace.ts +48 -1
  100. package/src/generators/antigravity.ts +32 -0
  101. package/src/generators/index.ts +23 -1
  102. package/src/generators/skills-generator.ts +5 -1
  103. package/src/orchestration/runner.ts +112 -108
  104. package/src/server.ts +62 -2
  105. package/src/utils/detect-ai.ts +4 -1
  106. package/src/utils/detect-editors.test.ts +7 -6
  107. package/src/utils/detect-editors.ts +5 -2
  108. package/src/utils/prompts.ts +88 -14
  109. package/src/utils/update-check.ts +16 -4
  110. package/dist/gui/assets/index-Ca2VRMrQ.js +0 -22
  111. package/dist/gui/assets/index-fj0RJiOb.css +0 -2
@@ -1,22 +1,19 @@
1
1
  /**
2
2
  * @module orchestration/runner
3
3
  * Manages the lifecycle of services โ€” start, stop, status, and log streaming.
4
- * Spawns child processes and tracks them via a state file.
4
+ * Uses PM2 for process management.
5
5
  */
6
6
 
7
7
  import * as fs from 'node:fs/promises';
8
8
  import * as path from 'node:path';
9
- import { spawn, type ChildProcess } from 'node:child_process';
10
9
  import chalk from 'chalk';
10
+ import { execa } from 'execa';
11
11
 
12
12
  import type { ServiceConfig, RunningService, RunningState } from '../types.js';
13
13
 
14
14
  /** Name of the state file that tracks running services. */
15
15
  const STATE_FILE = '.nexusflow-running.json';
16
16
 
17
- /** Active child processes tracked in memory. */
18
- const activeProcesses = new Map<string, ChildProcess>();
19
-
20
17
  /**
21
18
  * Returns the path to the running-state file for a workspace.
22
19
  */
@@ -25,12 +22,39 @@ function getStatePath(workspacePath: string): string {
25
22
  }
26
23
 
27
24
  /**
28
- * Loads the running state from disk.
25
+ * Loads the running state from disk, syncing active status with PM2.
29
26
  */
30
27
  export async function loadRunningState(workspacePath: string): Promise<RunningState | null> {
31
28
  try {
32
29
  const raw = await fs.readFile(getStatePath(workspacePath), 'utf-8');
33
- return JSON.parse(raw) as RunningState;
30
+ const state = JSON.parse(raw) as RunningState;
31
+
32
+ try {
33
+ // Query current PM2 process list to verify actual running status
34
+ const { stdout } = await execa('npx', ['pm2', 'jlist']);
35
+ const pm2List = JSON.parse(stdout);
36
+ const workspaceId = path.basename(workspacePath);
37
+ const prefix = `nexusflow-${workspaceId}-`;
38
+
39
+ const activeServices = state.services.map((service) => {
40
+ const uniqueName = `${prefix}${service.name}`;
41
+ const pm2App = pm2List.find((app: any) => app.name === uniqueName);
42
+ const running = pm2App && pm2App.pm2_env?.status === 'online';
43
+ return {
44
+ ...service,
45
+ pid: running ? (pm2App.pid || service.pid) : 0,
46
+ };
47
+ }).filter((service) => service.pid > 0);
48
+
49
+ return {
50
+ ...state,
51
+ services: activeServices,
52
+ updatedAt: new Date().toISOString(),
53
+ };
54
+ } catch {
55
+ // Fallback to cached state on disk if PM2 query fails
56
+ return state;
57
+ }
34
58
  } catch {
35
59
  return null;
36
60
  }
@@ -56,20 +80,7 @@ async function clearRunningState(workspacePath: string): Promise<void> {
56
80
  }
57
81
 
58
82
  /**
59
- * Checks if a process with the given PID is still running.
60
- */
61
- function isProcessRunning(pid: number): boolean {
62
- try {
63
- process.kill(pid, 0);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
- /**
71
- * Starts all services in the given list.
72
- * Each service is spawned as a detached child process.
83
+ * Starts all services in the given list using PM2.
73
84
  *
74
85
  * @param services - Services to start.
75
86
  * @param workspacePath - Workspace root path.
@@ -84,63 +95,66 @@ export async function startServices(
84
95
  await fs.mkdir(logDir, { recursive: true });
85
96
 
86
97
  const runningServices: RunningService[] = [];
98
+ const workspaceId = path.basename(workspacePath);
87
99
 
88
100
  for (const service of services) {
89
101
  const logFile = path.join(logDir, `${service.name}.log`);
90
102
  const portStr = service.port ? ` on port ${service.port}` : '';
103
+ const uniqueName = `nexusflow-${workspaceId}-${service.name}`;
91
104
 
92
105
  console.log(
93
- chalk.cyan(` Starting ${chalk.bold(service.name)}${portStr}...`),
106
+ chalk.cyan(` Starting ${chalk.bold(service.name)}${portStr} under PM2...`),
94
107
  );
95
108
  console.log(
96
109
  chalk.dim(` ${service.command} ${service.args.join(' ')} (in ${service.cwd})`),
97
110
  );
98
111
 
99
112
  try {
100
- // Ensure parent directory for this log file exists (supports nested names)
113
+ // Ensure parent directory for this log file exists
101
114
  await fs.mkdir(path.dirname(logFile), { recursive: true });
102
- // Open log file for writing
103
- const logFd = await fs.open(logFile, 'w');
104
- const logStream = logFd.createWriteStream();
105
-
106
- const child = spawn(service.command, service.args, {
107
- cwd: service.cwd,
108
- stdio: ['ignore', 'pipe', 'pipe'],
109
- detached: true,
110
- shell: true,
111
- });
112
-
113
- // Pipe stdout and stderr to log file
114
- child.stdout?.pipe(logStream);
115
- child.stderr?.pipe(logStream);
116
-
117
- child.on('error', (err) => {
118
- console.error(chalk.red(` โœ– ${service.name} error: ${err.message}`));
119
- });
120
-
121
- child.on('exit', (code) => {
122
- if (code !== null && code !== 0) {
123
- console.log(chalk.yellow(` โš  ${service.name} exited with code ${code}`));
124
- }
125
- activeProcesses.delete(service.name);
126
- });
127
-
128
- // Don't wait for the child to finish
129
- child.unref();
130
-
131
- if (child.pid) {
132
- activeProcesses.set(service.name, child);
115
+
116
+ // Delete any existing PM2 configuration with the same name to avoid duplicate starts
117
+ await execa('npx', ['pm2', 'delete', uniqueName], { reject: false });
118
+
119
+ // Start the service using PM2 as a direct process execution
120
+ await execa('npx', [
121
+ 'pm2',
122
+ 'start',
123
+ service.command,
124
+ '--name',
125
+ uniqueName,
126
+ '--cwd',
127
+ service.cwd,
128
+ '-o',
129
+ logFile,
130
+ '-e',
131
+ logFile,
132
+ '--interpreter',
133
+ 'none',
134
+ '--',
135
+ ...service.args
136
+ ]);
137
+
138
+ // Retrieve the real PID from PM2
139
+ const { stdout } = await execa('npx', ['pm2', 'jlist']);
140
+ const pm2List = JSON.parse(stdout);
141
+ const pm2App = pm2List.find((app: any) => app.name === uniqueName);
142
+ const pid = pm2App?.pid || 0;
143
+
144
+ if (pid) {
133
145
  runningServices.push({
134
146
  name: service.name,
135
- pid: child.pid,
147
+ pid,
136
148
  config: service,
137
149
  startedAt: new Date().toISOString(),
138
150
  });
139
- console.log(chalk.green(` โœ” ${service.name} started (PID: ${child.pid})`));
151
+ console.log(chalk.green(` โœ” ${service.name} started under PM2 (PID: ${pid})`));
152
+ } else {
153
+ console.warn(chalk.yellow(` โš  Started ${service.name} but could not resolve PID from PM2.`));
140
154
  }
141
155
  } catch (error) {
142
156
  const msg = error instanceof Error ? error.message : String(error);
143
- console.error(chalk.red(` โœ– Failed to start ${service.name}: ${msg}`));
157
+ console.error(chalk.red(` โœ– Failed to start ${service.name} via PM2: ${msg}`));
144
158
  }
145
159
  }
146
160
 
@@ -155,79 +169,69 @@ export async function startServices(
155
169
  }
156
170
 
157
171
  /**
158
- * Stops all services for a workspace.
172
+ * Stops all services for a workspace under PM2.
159
173
  *
160
174
  * @param workspacePath - Workspace root path.
161
175
  */
162
176
  export async function stopServices(workspacePath: string): Promise<void> {
163
- const state = await loadRunningState(workspacePath);
177
+ const workspaceId = path.basename(workspacePath);
178
+ const prefix = `nexusflow-${workspaceId}-`;
164
179
 
165
- if (!state || state.services.length === 0) {
166
- console.log(chalk.yellow(' No running services found.'));
167
- return;
168
- }
180
+ console.log(chalk.cyan(` Stopping all services under PM2 for workspace: ${workspaceId}...`));
169
181
 
170
- for (const service of state.services) {
171
- // Try in-memory process first
172
- const child = activeProcesses.get(service.name);
173
- if (child && child.pid) {
174
- try {
175
- // Kill the process group (negative PID kills group on Unix, taskkill on Windows)
176
- if (process.platform === 'win32') {
177
- spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
178
- } else {
179
- process.kill(-child.pid, 'SIGTERM');
180
- }
181
- console.log(chalk.green(` โœ” Stopped ${service.name} (PID: ${service.pid})`));
182
- activeProcesses.delete(service.name);
183
- continue;
184
- } catch {
185
- // Fall through to PID-based kill
186
- }
187
- }
182
+ try {
183
+ const { stdout } = await execa('npx', ['pm2', 'jlist']);
184
+ const pm2List = JSON.parse(stdout);
185
+ const targetApps = pm2List.filter((app: any) => app.name && app.name.startsWith(prefix));
188
186
 
189
- // Fall back to PID from state file
190
- if (isProcessRunning(service.pid)) {
191
- try {
192
- if (process.platform === 'win32') {
193
- spawn('taskkill', ['/pid', String(service.pid), '/f', '/t'], { stdio: 'ignore' });
194
- } else {
195
- process.kill(service.pid, 'SIGTERM');
196
- }
197
- console.log(chalk.green(` โœ” Stopped ${service.name} (PID: ${service.pid})`));
198
- } catch {
199
- console.log(chalk.yellow(` โš  Could not stop ${service.name} (PID: ${service.pid})`));
200
- }
187
+ if (targetApps.length === 0) {
188
+ console.log(chalk.yellow(' No running services found for this workspace.'));
201
189
  } else {
202
- console.log(chalk.dim(` ${service.name} already stopped`));
190
+ for (const app of targetApps) {
191
+ console.log(chalk.dim(` Stopping PM2 process: ${app.name}`));
192
+ await execa('npx', ['pm2', 'delete', app.name], { reject: false });
193
+ }
194
+ console.log(chalk.green(` โœ” All services stopped.`));
203
195
  }
196
+ } catch (error: any) {
197
+ console.error(chalk.red(` โœ– Failed to stop services via PM2: ${error.message}`));
204
198
  }
205
199
 
206
200
  await clearRunningState(workspacePath);
207
201
  }
208
202
 
209
203
  /**
210
- * Shows the status of all services in a workspace.
204
+ * Shows the status of all services in a workspace by querying PM2.
211
205
  *
212
206
  * @param workspacePath - Workspace root path.
213
207
  */
214
208
  export async function getServiceStatus(workspacePath: string): Promise<void> {
215
- const state = await loadRunningState(workspacePath);
209
+ const workspaceId = path.basename(workspacePath);
210
+ const prefix = `nexusflow-${workspaceId}-`;
216
211
 
217
- if (!state || state.services.length === 0) {
218
- console.log(chalk.yellow(' No services tracked for this workspace.'));
219
- return;
220
- }
212
+ try {
213
+ const { stdout } = await execa('npx', ['pm2', 'jlist']);
214
+ const pm2List = JSON.parse(stdout);
215
+ const targetApps = pm2List.filter((app: any) => app.name && app.name.startsWith(prefix));
221
216
 
222
- for (const service of state.services) {
223
- const running = isProcessRunning(service.pid);
224
- const status = running ? chalk.green('โ— running') : chalk.red('โ— stopped');
225
- const portStr = service.config.port ? ` :${service.config.port}` : '';
226
- const since = new Date(service.startedAt).toLocaleTimeString();
217
+ if (targetApps.length === 0) {
218
+ console.log(chalk.yellow(' No running services found for this workspace in PM2.'));
219
+ return;
220
+ }
227
221
 
228
- console.log(
229
- ` ${status} ${chalk.bold(service.name)}${chalk.dim(portStr)} (PID: ${service.pid}, since ${since})`,
230
- );
222
+ for (const app of targetApps) {
223
+ const name = app.name.substring(prefix.length);
224
+ const running = app.pm2_env?.status === 'online';
225
+ const status = running ? chalk.green('โ— running') : chalk.red(`โ— ${app.pm2_env?.status || 'stopped'}`);
226
+ const pid = app.pid || 'N/A';
227
+ const uptime = app.pm2_env?.pm_uptime ? new Date(app.pm2_env.pm_uptime).toLocaleTimeString() : 'unknown';
228
+
229
+ console.log(
230
+ ` ${status} ${chalk.bold(name)} (PID: ${pid}, since ${uptime})`,
231
+ );
232
+ }
233
+ } catch (error: any) {
234
+ console.error(chalk.red(` โœ– Failed to query PM2 status: ${error.message}`));
231
235
  }
232
236
  }
233
237
 
package/src/server.ts CHANGED
@@ -46,7 +46,7 @@ const guiPath = path.join(__dirname, 'gui');
46
46
  export const app = new Hono();
47
47
 
48
48
  // Allowed editor binaries/scripts to prevent command injection
49
- const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
49
+ const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'antigravity', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
50
50
 
51
51
  // Enable CORS for frontend dev server
52
52
  app.use('/api/*', cors());
@@ -700,6 +700,63 @@ app.get('/api/workspace/:id/changes', async (c) => {
700
700
  }
701
701
  });
702
702
 
703
+ // 13_diff. Get git diff for a specific file in workspace sub-repositories
704
+ app.get('/api/workspace/:id/changes/diff', async (c) => {
705
+ try {
706
+ const id = c.req.param('id');
707
+ const repoName = c.req.query('repo');
708
+ const filePath = c.req.query('file');
709
+
710
+ if (!repoName || !filePath) {
711
+ return c.json({ error: 'Missing repo or file query parameter.' }, 400);
712
+ }
713
+
714
+ const config = await loadConfig();
715
+ const workspacePath = path.join(config.workspacesDir, id);
716
+ const worktreePath = path.join(workspacePath, repoName);
717
+
718
+ // Security check: ensure path is within workspace directory
719
+ const resolvedPath = path.resolve(worktreePath);
720
+ if (!resolvedPath.startsWith(path.resolve(workspacePath))) {
721
+ return c.json({ error: 'Invalid repository path.' }, 400);
722
+ }
723
+
724
+ let diff = '';
725
+
726
+ // Check git status for the file to know if it's untracked
727
+ try {
728
+ const { stdout: statusOut } = await execa('git', ['status', '--porcelain', '--', filePath], { cwd: worktreePath });
729
+ const statusLine = statusOut.trim();
730
+ const isUntracked = statusLine.startsWith('??');
731
+
732
+ if (isUntracked) {
733
+ const result = await execa('git', ['diff', '--no-index', '--', '/dev/null', filePath], {
734
+ cwd: worktreePath,
735
+ reject: false
736
+ });
737
+ diff = result.stdout || result.stderr || '';
738
+ } else {
739
+ const result = await execa('git', ['diff', 'HEAD', '--', filePath], {
740
+ cwd: worktreePath,
741
+ reject: false
742
+ });
743
+ diff = result.stdout || result.stderr || '';
744
+ }
745
+ } catch (e) {
746
+ const result = await execa('git', ['diff', 'HEAD', '--', filePath], {
747
+ cwd: worktreePath,
748
+ reject: false
749
+ });
750
+ diff = result.stdout || result.stderr || '';
751
+ }
752
+
753
+ return c.json({ diff });
754
+ } catch (error) {
755
+ const msg = error instanceof Error ? error.message : String(error);
756
+ return c.json({ error: msg }, 500);
757
+ }
758
+ });
759
+
703
760
  // 13a. Get workspace knowledge (nexusflow-knowledge.md)
704
761
  app.get('/api/workspace/:id/knowledge', async (c) => {
705
762
  try {
@@ -978,7 +1035,10 @@ app.post('/api/updates/install', async (c) => {
978
1035
  return c.json({ error: 'Tool not found' }, 404);
979
1036
  }
980
1037
 
981
- const result = await execa(target.cmd, target.args, { reject: false });
1038
+ const result = await execa(target.cmd, target.args, {
1039
+ reject: false,
1040
+ shell: process.platform === 'win32',
1041
+ });
982
1042
  if (result.exitCode === 0) {
983
1043
  return c.json({ success: true, output: result.stdout });
984
1044
  } else {
@@ -13,7 +13,10 @@ import type { AIAssistant, DetectedAI } from '../types.js';
13
13
  */
14
14
  async function commandExists(command: string): Promise<boolean> {
15
15
  try {
16
- const result = await execa(command, ['--version'], { reject: false });
16
+ const result = await execa(command, ['--version'], {
17
+ reject: false,
18
+ shell: process.platform === 'win32',
19
+ });
17
20
  return result.exitCode === 0;
18
21
  } catch {
19
22
  // The command could not be spawned at all (not in PATH).
@@ -23,13 +23,14 @@ describe('detectEditors', () => {
23
23
  { name: 'VS Code', command: 'code', detected: true },
24
24
  { name: 'VS Code Insiders', command: 'code-insiders', detected: false },
25
25
  { name: 'Cursor', command: 'cursor', detected: true },
26
- { name: 'Antigravity', command: 'agy', detected: false },
26
+ { name: 'Antigravity', command: 'antigravity', detected: false },
27
27
  ]);
28
28
 
29
- expect(execa).toHaveBeenCalledWith('code', ['--version'], { reject: false });
30
- expect(execa).toHaveBeenCalledWith('code-insiders', ['--version'], { reject: false });
31
- expect(execa).toHaveBeenCalledWith('cursor', ['--version'], { reject: false });
32
- expect(execa).toHaveBeenCalledWith('agy', ['--version'], { reject: false });
29
+ const isWin = process.platform === 'win32';
30
+ expect(execa).toHaveBeenCalledWith('code', ['--version'], { reject: false, shell: isWin });
31
+ expect(execa).toHaveBeenCalledWith('code-insiders', ['--version'], { reject: false, shell: isWin });
32
+ expect(execa).toHaveBeenCalledWith('cursor', ['--version'], { reject: false, shell: isWin });
33
+ expect(execa).toHaveBeenCalledWith('antigravity', ['--version'], { reject: false, shell: isWin });
33
34
  });
34
35
 
35
36
  it('should return detected = false for all editors if execa throws an error', async () => {
@@ -41,7 +42,7 @@ describe('detectEditors', () => {
41
42
  { name: 'VS Code', command: 'code', detected: false },
42
43
  { name: 'VS Code Insiders', command: 'code-insiders', detected: false },
43
44
  { name: 'Cursor', command: 'cursor', detected: false },
44
- { name: 'Antigravity', command: 'agy', detected: false },
45
+ { name: 'Antigravity', command: 'antigravity', detected: false },
45
46
  ]);
46
47
  });
47
48
  });
@@ -12,7 +12,7 @@ const EDITOR_CANDIDATES: ReadonlyArray<{ name: string; command: string }> = [
12
12
  { name: 'VS Code', command: 'code' },
13
13
  { name: 'VS Code Insiders', command: 'code-insiders' },
14
14
  { name: 'Cursor', command: 'cursor' },
15
- { name: 'Antigravity', command: 'agy' },
15
+ { name: 'Antigravity', command: 'antigravity' },
16
16
  ];
17
17
 
18
18
  /**
@@ -21,7 +21,10 @@ const EDITOR_CANDIDATES: ReadonlyArray<{ name: string; command: string }> = [
21
21
  */
22
22
  async function commandExists(command: string): Promise<boolean> {
23
23
  try {
24
- const result = await execa(command, ['--version'], { reject: false });
24
+ const result = await execa(command, ['--version'], {
25
+ reject: false,
26
+ shell: process.platform === 'win32',
27
+ });
25
28
  return result.exitCode === 0;
26
29
  } catch {
27
30
  return false;
@@ -3,7 +3,7 @@
3
3
  * Interactive CLI prompts for NexusFlow using @inquirer/prompts.
4
4
  */
5
5
 
6
- import { input, checkbox, confirm } from '@inquirer/prompts';
6
+ import { input, checkbox, confirm, select, search } from '@inquirer/prompts';
7
7
  import chalk from 'chalk';
8
8
 
9
9
  import type { AIAssistant, DetectedAI, DetectedEditor, RepoInfo } from '../types.js';
@@ -57,26 +57,100 @@ export async function promptDescription(): Promise<string> {
57
57
 
58
58
  /**
59
59
  * Prompts the user to select repos from a list of discovered repos.
60
+ * Supports searching/filtering by term, viewing all, and incremental selection.
60
61
  */
61
62
  export async function promptSelectRepos(repos: RepoInfo[]): Promise<RepoInfo[]> {
62
63
  if (repos.length === 0) {
63
64
  return [];
64
65
  }
65
66
 
66
- const selected = await checkbox({
67
- message: 'Select projects to include:',
68
- choices: repos.map((repo) => ({
69
- name: `${repo.name} ${chalk.dim(`(${repo.path})`)}`,
70
- value: repo.path,
71
- checked: false,
72
- })),
73
- validate: (items) => {
74
- if (items.length === 0) return 'Please select at least one project';
75
- return true;
76
- },
77
- });
67
+ const selectedPaths = new Set<string>();
68
+
69
+ // If there are only a few repos (e.g. <= 10), we can just show a checkbox prompt directly to save time
70
+ if (repos.length <= 10) {
71
+ const toggled = await checkbox({
72
+ message: 'Select repositories to include in workspace:',
73
+ choices: repos.map((repo) => ({
74
+ name: `${repo.name} ${chalk.dim(`(${repo.path})`)}`,
75
+ value: repo.path,
76
+ checked: false,
77
+ })),
78
+ });
79
+ return repos.filter((r) => toggled.includes(r.path));
80
+ }
81
+
82
+ // Otherwise, use a loop with the search prompt to make it extremely easy to filter and toggle selection
83
+ while (true) {
84
+ console.log(chalk.bold(`\n๐Ÿ“ Selected Repositories (${selectedPaths.size}/${repos.length}):`));
85
+ if (selectedPaths.size === 0) {
86
+ console.log(chalk.dim(' (None selected yet)'));
87
+ } else {
88
+ repos.forEach((r) => {
89
+ if (selectedPaths.has(r.path)) {
90
+ console.log(` ${chalk.green('โœ“')} ${r.name} ${chalk.dim(`(${r.path})`)}`);
91
+ }
92
+ });
93
+ }
94
+ console.log();
95
+
96
+ const result = await search({
97
+ message: 'Search and select repositories (type to filter, select to toggle, choose Done to finish):',
98
+ source: async (input) => {
99
+ const query = (input || '').toLowerCase();
100
+ const filtered = repos.filter(
101
+ (r) =>
102
+ r.name.toLowerCase().includes(query) ||
103
+ r.path.toLowerCase().includes(query)
104
+ );
105
+
106
+ const choices = [
107
+ {
108
+ name: chalk.green(`โœ… Done selecting (${selectedPaths.size} repo(s) selected)`),
109
+ value: 'done',
110
+ },
111
+ ];
112
+
113
+ if (selectedPaths.size > 0) {
114
+ choices.push({
115
+ name: chalk.yellow('๐Ÿงน Clear all selections'),
116
+ value: 'clear',
117
+ });
118
+ }
119
+
120
+ filtered.forEach((r) => {
121
+ const isSelected = selectedPaths.has(r.path);
122
+ choices.push({
123
+ name: `${isSelected ? chalk.green('โœ“') : ' '} ${r.name} ${chalk.dim(`(${r.path})`)}`,
124
+ value: r.path,
125
+ });
126
+ });
127
+
128
+ return choices;
129
+ },
130
+ });
131
+
132
+ if (result === 'done') {
133
+ if (selectedPaths.size === 0) {
134
+ console.log(chalk.red(' Please select at least one repository.'));
135
+ continue;
136
+ }
137
+ break;
138
+ }
139
+
140
+ if (result === 'clear') {
141
+ selectedPaths.clear();
142
+ continue;
143
+ }
144
+
145
+ // Toggle selected state
146
+ if (selectedPaths.has(result)) {
147
+ selectedPaths.delete(result);
148
+ } else {
149
+ selectedPaths.add(result);
150
+ }
151
+ }
78
152
 
79
- return repos.filter((r) => selected.includes(r.path));
153
+ return repos.filter((r) => selectedPaths.has(r.path));
80
154
  }
81
155
 
82
156
  /**
@@ -178,11 +178,17 @@ export async function getToolsStatus(force = false): Promise<ToolUpdateStatus[]>
178
178
  updateCmd: 'npm install -g repomix',
179
179
  getCurrent: async () => {
180
180
  try {
181
- const res = await execa('repomix', ['--version'], { reject: false });
181
+ const res = await execa('repomix', ['--version'], {
182
+ reject: false,
183
+ shell: process.platform === 'win32',
184
+ });
182
185
  if (res.exitCode === 0) return res.stdout.trim();
183
186
  } catch {}
184
187
  try {
185
- const res = await execa('npx', ['repomix', '--version'], { reject: false });
188
+ const res = await execa('npx', ['repomix', '--version'], {
189
+ reject: false,
190
+ shell: process.platform === 'win32',
191
+ });
186
192
  if (res.exitCode === 0) return res.stdout.trim();
187
193
  } catch {}
188
194
  return '';
@@ -196,7 +202,10 @@ export async function getToolsStatus(force = false): Promise<ToolUpdateStatus[]>
196
202
  updateCmd: 'agy update',
197
203
  getCurrent: async () => {
198
204
  try {
199
- const res = await execa('agy', ['--version'], { reject: false });
205
+ const res = await execa('agy', ['--version'], {
206
+ reject: false,
207
+ shell: process.platform === 'win32',
208
+ });
200
209
  if (res.exitCode === 0) return res.stdout.trim();
201
210
  } catch {}
202
211
  return '';
@@ -210,7 +219,10 @@ export async function getToolsStatus(force = false): Promise<ToolUpdateStatus[]>
210
219
  updateCmd: 'npm install -g @anthropic-ai/claude-code',
211
220
  getCurrent: async () => {
212
221
  try {
213
- const res = await execa('claude', ['--version'], { reject: false });
222
+ const res = await execa('claude', ['--version'], {
223
+ reject: false,
224
+ shell: process.platform === 'win32',
225
+ });
214
226
  if (res.exitCode === 0) return res.stdout.trim();
215
227
  } catch {}
216
228
  return '';