@sylphx/flow 2.0.0 → 2.1.1

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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Target Installation Service
3
+ * Auto-detects and installs AI CLI tools (Claude Code, OpenCode, Cursor)
4
+ */
5
+
6
+ import { exec } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import chalk from 'chalk';
9
+ import ora from 'ora';
10
+ import inquirer from 'inquirer';
11
+ import { detectPackageManager, type PackageManager } from '../utils/package-manager-detector.js';
12
+ import { UserCancelledError } from '../utils/errors.js';
13
+
14
+ const execAsync = promisify(exec);
15
+
16
+ export interface TargetInstallation {
17
+ id: 'claude-code' | 'opencode' | 'cursor';
18
+ name: string;
19
+ package: string;
20
+ checkCommand: string;
21
+ installCommand: (pm: PackageManager) => string;
22
+ }
23
+
24
+ /**
25
+ * Supported target installations
26
+ */
27
+ const TARGET_INSTALLATIONS: TargetInstallation[] = [
28
+ {
29
+ id: 'claude-code',
30
+ name: 'Claude Code',
31
+ package: '@anthropic-ai/claude-code',
32
+ checkCommand: 'claude --version',
33
+ installCommand: (pm: PackageManager) => {
34
+ switch (pm) {
35
+ case 'npm':
36
+ return 'npm install -g @anthropic-ai/claude-code';
37
+ case 'bun':
38
+ return 'bun install -g @anthropic-ai/claude-code';
39
+ case 'pnpm':
40
+ return 'pnpm install -g @anthropic-ai/claude-code';
41
+ case 'yarn':
42
+ return 'yarn global add @anthropic-ai/claude-code';
43
+ }
44
+ },
45
+ },
46
+ {
47
+ id: 'opencode',
48
+ name: 'OpenCode',
49
+ package: 'opencode-ai',
50
+ checkCommand: 'opencode --version',
51
+ installCommand: (pm: PackageManager) => {
52
+ switch (pm) {
53
+ case 'npm':
54
+ return 'npm install -g opencode-ai@latest';
55
+ case 'bun':
56
+ return 'bun install -g opencode-ai@latest';
57
+ case 'pnpm':
58
+ return 'pnpm install -g opencode-ai@latest';
59
+ case 'yarn':
60
+ return 'yarn global add opencode-ai@latest';
61
+ }
62
+ },
63
+ },
64
+ {
65
+ id: 'cursor',
66
+ name: 'Cursor',
67
+ package: 'cursor',
68
+ checkCommand: 'cursor --version',
69
+ installCommand: () => {
70
+ // Cursor is typically installed via installer, not npm
71
+ return 'Visit https://cursor.sh to download and install';
72
+ },
73
+ },
74
+ ];
75
+
76
+ export class TargetInstaller {
77
+ private packageManager: PackageManager;
78
+
79
+ constructor(projectPath: string = process.cwd()) {
80
+ this.packageManager = detectPackageManager(projectPath);
81
+ }
82
+
83
+ /**
84
+ * Check if a target CLI is installed
85
+ * @param targetId - Target ID to check
86
+ * @returns True if installed, false otherwise
87
+ */
88
+ async isInstalled(targetId: string): Promise<boolean> {
89
+ const installation = TARGET_INSTALLATIONS.find((t) => t.id === targetId);
90
+ if (!installation) {
91
+ return false;
92
+ }
93
+
94
+ try {
95
+ await execAsync(installation.checkCommand);
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Detect which target CLIs are currently installed
104
+ * @returns Array of installed target IDs
105
+ */
106
+ async detectInstalledTargets(): Promise<string[]> {
107
+ const installed: string[] = [];
108
+
109
+ for (const installation of TARGET_INSTALLATIONS) {
110
+ if (await this.isInstalled(installation.id)) {
111
+ installed.push(installation.id);
112
+ }
113
+ }
114
+
115
+ return installed;
116
+ }
117
+
118
+ /**
119
+ * Prompt user to select a target to install
120
+ * @returns Selected target ID
121
+ * @throws {UserCancelledError} If user cancels the prompt
122
+ */
123
+ async promptForTargetSelection(): Promise<string> {
124
+ try {
125
+ const { targetId } = await inquirer.prompt([
126
+ {
127
+ type: 'list',
128
+ name: 'targetId',
129
+ message: 'No AI CLI detected. Which would you like to use?',
130
+ choices: TARGET_INSTALLATIONS.map((t) => ({
131
+ name: t.name,
132
+ value: t.id,
133
+ })),
134
+ default: 'claude-code',
135
+ },
136
+ ]);
137
+
138
+ return targetId;
139
+ } catch (error: any) {
140
+ // Handle user cancellation (Ctrl+C)
141
+ if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
142
+ throw new UserCancelledError('Target selection cancelled');
143
+ }
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Install a target CLI using detected package manager
150
+ * @param targetId - Target ID to install
151
+ * @param autoConfirm - Skip confirmation prompt if true
152
+ * @returns True if installation successful, false otherwise
153
+ * @throws {UserCancelledError} If user cancels installation
154
+ */
155
+ async install(targetId: string, autoConfirm: boolean = false): Promise<boolean> {
156
+ const installation = TARGET_INSTALLATIONS.find((t) => t.id === targetId);
157
+ if (!installation) {
158
+ console.log(chalk.red(`✗ Unknown target: ${targetId}`));
159
+ return false;
160
+ }
161
+
162
+ // Special handling for Cursor (not npm-installable)
163
+ if (targetId === 'cursor') {
164
+ console.log(chalk.yellow('\n⚠️ Cursor requires manual installation'));
165
+ console.log(chalk.cyan(' Visit https://cursor.sh to download and install\n'));
166
+ return false;
167
+ }
168
+
169
+ // Confirm installation unless auto-confirm is enabled
170
+ if (!autoConfirm) {
171
+ try {
172
+ const { confirmInstall } = await inquirer.prompt([
173
+ {
174
+ type: 'confirm',
175
+ name: 'confirmInstall',
176
+ message: `Install ${installation.name}?`,
177
+ default: true,
178
+ },
179
+ ]);
180
+
181
+ if (!confirmInstall) {
182
+ console.log(chalk.yellow('\n⚠️ Installation cancelled\n'));
183
+ return false;
184
+ }
185
+ } catch (error: any) {
186
+ // Handle user cancellation (Ctrl+C)
187
+ if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
188
+ throw new UserCancelledError('Installation cancelled');
189
+ }
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ const spinner = ora(`Installing ${installation.name}...`).start();
195
+
196
+ try {
197
+ const installCmd = installation.installCommand(this.packageManager);
198
+ await execAsync(installCmd);
199
+
200
+ spinner.succeed(chalk.green(`✓ ${installation.name} installed successfully`));
201
+ return true;
202
+ } catch (error) {
203
+ spinner.fail(chalk.red(`✗ Failed to install ${installation.name}`));
204
+
205
+ const installCmd = installation.installCommand(this.packageManager);
206
+ console.log(chalk.yellow('\n⚠️ Auto-install failed. Please run manually:'));
207
+ console.log(chalk.cyan(` ${installCmd}\n`));
208
+
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Auto-detect installed targets or prompt to install one
215
+ * @returns Target ID if found or installed, null if installation failed
216
+ * @throws {UserCancelledError} If user cancels selection or installation
217
+ */
218
+ async autoDetectAndInstall(): Promise<string | null> {
219
+ console.log(chalk.cyan('🔍 Detecting installed AI CLIs...\n'));
220
+
221
+ const installedTargets = await this.detectInstalledTargets();
222
+
223
+ // If we found installed targets, return the first one (priority order)
224
+ if (installedTargets.length > 0) {
225
+ const targetId = installedTargets[0];
226
+ const installation = TARGET_INSTALLATIONS.find((t) => t.id === targetId);
227
+ console.log(chalk.green(`✓ Found ${installation?.name}\n`));
228
+ return targetId;
229
+ }
230
+
231
+ // No targets found - prompt user to select one
232
+ console.log(chalk.yellow('⚠️ No AI CLI detected\n'));
233
+ const selectedTargetId = await this.promptForTargetSelection();
234
+
235
+ // Try to install the selected target
236
+ console.log();
237
+ const installed = await this.install(selectedTargetId, false);
238
+
239
+ if (!installed) {
240
+ return null;
241
+ }
242
+
243
+ return selectedTargetId;
244
+ }
245
+
246
+ /**
247
+ * Get installation metadata for a target
248
+ * @param targetId - Target ID to get info for
249
+ * @returns Installation info or undefined if target not found
250
+ */
251
+ getInstallationInfo(targetId: string): TargetInstallation | undefined {
252
+ return TARGET_INSTALLATIONS.find((t) => t.id === targetId);
253
+ }
254
+ }
@@ -3,8 +3,8 @@ import fs from 'node:fs';
3
3
  import fsPromises from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import chalk from 'chalk';
6
- import { FileInstaller } from '../core/installers/file-installer.js';
7
- import { MCPInstaller } from '../core/installers/mcp-installer.js';
6
+ import { installToDirectory } from '../core/installers/file-installer.js';
7
+ import { createMCPInstaller } from '../core/installers/mcp-installer.js';
8
8
  import type { AgentMetadata } from '../types/target-config.types.js';
9
9
  import type { CommonOptions, MCPServerConfigUnion, SetupResult, Target } from '../types.js';
10
10
  import { CLIError } from '../utils/error-handler.js';
@@ -432,10 +432,9 @@ Please begin your response with a comprehensive summary of all the instructions
432
432
  */
433
433
  async setupAgents(cwd: string, options: CommonOptions): Promise<SetupResult> {
434
434
  const { enhanceAgentContent } = await import('../utils/agent-enhancer.js');
435
- const installer = new FileInstaller();
436
435
  const agentsDir = path.join(cwd, this.config.agentDir);
437
436
 
438
- const results = await installer.installToDirectory(
437
+ const results = await installToDirectory(
439
438
  getAgentsDir(),
440
439
  agentsDir,
441
440
  async (content, sourcePath) => {
@@ -491,7 +490,7 @@ Please begin your response with a comprehensive summary of all the instructions
491
490
  * Select, configure, install, and approve MCP servers
492
491
  */
493
492
  async setupMCP(cwd: string, options: CommonOptions): Promise<SetupResult> {
494
- const installer = new MCPInstaller(this);
493
+ const installer = createMCPInstaller(this);
495
494
  const result = await installer.setupMCP({ ...options, quiet: true });
496
495
 
497
496
  // Approve servers in Claude Code settings
@@ -513,10 +512,9 @@ Please begin your response with a comprehensive summary of all the instructions
513
512
  return { count: 0 };
514
513
  }
515
514
 
516
- const installer = new FileInstaller();
517
515
  const slashCommandsDir = path.join(cwd, this.config.slashCommandsDir);
518
516
 
519
- const results = await installer.installToDirectory(
517
+ const results = await installToDirectory(
520
518
  getSlashCommandsDir(),
521
519
  slashCommandsDir,
522
520
  async (content) => {
@@ -3,8 +3,8 @@ import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { getRulesPath, ruleFileExists } from '../config/rules.js';
5
5
  import { MCP_SERVER_REGISTRY } from '../config/servers.js';
6
- import { FileInstaller } from '../core/installers/file-installer.js';
7
- import { MCPInstaller } from '../core/installers/mcp-installer.js';
6
+ import { installToDirectory, installFile } from '../core/installers/file-installer.js';
7
+ import { createMCPInstaller } from '../core/installers/mcp-installer.js';
8
8
  import type { AgentMetadata } from '../types/target-config.types.js';
9
9
  import type { CommonOptions, MCPServerConfigUnion, SetupResult, Target } from '../types.js';
10
10
  import { getAgentsDir, getOutputStylesDir, getSlashCommandsDir } from '../utils/config/paths.js';
@@ -232,19 +232,9 @@ export const opencodeTarget: Target = {
232
232
  * Install agents to .opencode/agent/ directory
233
233
  */
234
234
  async setupAgents(cwd: string, options: CommonOptions): Promise<SetupResult> {
235
- // Clean up old 'commands' directory if it exists (migration from old structure)
236
- // This ensures OpenCode won't crash with ConfigDirectoryTypoError
237
- const oldCommandsDir = path.join(cwd, '.opencode/commands');
238
- try {
239
- await fs.rm(oldCommandsDir, { recursive: true, force: true });
240
- } catch {
241
- // Ignore if doesn't exist
242
- }
243
-
244
- const installer = new FileInstaller();
245
235
  const agentsDir = path.join(cwd, this.config.agentDir);
246
236
 
247
- const results = await installer.installToDirectory(
237
+ const results = await installToDirectory(
248
238
  getAgentsDir(),
249
239
  agentsDir,
250
240
  async (content, sourcePath) => {
@@ -336,11 +326,10 @@ export const opencodeTarget: Target = {
336
326
  throw new Error('Core rules file not found');
337
327
  }
338
328
 
339
- const installer = new FileInstaller();
340
329
  const rulesDestPath = path.join(cwd, this.config.rulesFile);
341
330
  const rulePath = getRulesPath('core');
342
331
 
343
- await installer.installFile(
332
+ await installFile(
344
333
  rulePath,
345
334
  rulesDestPath,
346
335
  async (content) => {
@@ -371,7 +360,7 @@ export const opencodeTarget: Target = {
371
360
  }
372
361
 
373
362
  // Install MCP servers
374
- const installer = new MCPInstaller(this);
363
+ const installer = createMCPInstaller(this);
375
364
  const result = await installer.setupMCP({ ...options, quiet: true });
376
365
 
377
366
  return { count: result.selectedServers.length };
@@ -386,18 +375,9 @@ export const opencodeTarget: Target = {
386
375
  return { count: 0 };
387
376
  }
388
377
 
389
- // Clean up old 'commands' directory if it exists (migration from old structure)
390
- const oldCommandsDir = path.join(cwd, '.opencode/commands');
391
- try {
392
- await fs.rm(oldCommandsDir, { recursive: true, force: true });
393
- } catch {
394
- // Ignore if doesn't exist
395
- }
396
-
397
- const installer = new FileInstaller();
398
378
  const slashCommandsDir = path.join(cwd, this.config.slashCommandsDir);
399
379
 
400
- const results = await installer.installToDirectory(
380
+ const results = await installToDirectory(
401
381
  getSlashCommandsDir(),
402
382
  slashCommandsDir,
403
383
  async (content) => {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Prompt Helpers
3
+ * Utilities for handling user prompts and error handling
4
+ */
5
+
6
+ import { UserCancelledError } from './errors.js';
7
+
8
+ /**
9
+ * Check if error is a user cancellation (Ctrl+C or force closed)
10
+ * @param error - Error to check
11
+ * @returns True if error represents user cancellation
12
+ */
13
+ export function isUserCancellation(error: any): boolean {
14
+ return error?.name === 'ExitPromptError' || error?.message?.includes('force closed');
15
+ }
16
+
17
+ /**
18
+ * Handle inquirer prompt errors with consistent error handling
19
+ * Throws UserCancelledError for user cancellations, re-throws other errors
20
+ * @param error - Error from inquirer prompt
21
+ * @param message - Custom cancellation message
22
+ * @throws {UserCancelledError} If user cancelled the prompt
23
+ * @throws Original error if not a cancellation
24
+ */
25
+ export function handlePromptError(error: any, message: string): never {
26
+ if (isUserCancellation(error)) {
27
+ throw new UserCancelledError(message);
28
+ }
29
+ throw error;
30
+ }
31
+
32
+ /**
33
+ * Wrap an inquirer prompt with consistent error handling
34
+ * @param promptFn - Function that returns a promise from inquirer
35
+ * @param errorMessage - Message to use if user cancels
36
+ * @returns Promise with the prompt result
37
+ * @throws {UserCancelledError} If user cancels the prompt
38
+ */
39
+ export async function withPromptErrorHandling<T>(
40
+ promptFn: () => Promise<T>,
41
+ errorMessage: string
42
+ ): Promise<T> {
43
+ try {
44
+ return await promptFn();
45
+ } catch (error) {
46
+ handlePromptError(error, errorMessage);
47
+ }
48
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Target Selection Utilities
3
+ * Shared logic for target selection UI across settings and execution flows
4
+ */
5
+
6
+ import chalk from 'chalk';
7
+ import inquirer from 'inquirer';
8
+ import { TargetInstaller } from '../services/target-installer.js';
9
+ import { handlePromptError } from './prompt-helpers.js';
10
+
11
+ /**
12
+ * Represents a target CLI choice with installation status
13
+ */
14
+ export interface TargetChoice {
15
+ /** Display name of the target */
16
+ name: string;
17
+ /** Target identifier */
18
+ value: 'claude-code' | 'opencode' | 'cursor';
19
+ /** Whether the target is currently installed */
20
+ installed: boolean;
21
+ }
22
+
23
+ /**
24
+ * Build list of available targets with installation status
25
+ * @param installedTargets - List of currently installed target IDs
26
+ * @returns Array of target choices with installation status
27
+ */
28
+ export function buildAvailableTargets(installedTargets: string[]): TargetChoice[] {
29
+ return [
30
+ {
31
+ name: 'Claude Code',
32
+ value: 'claude-code',
33
+ installed: installedTargets.includes('claude-code'),
34
+ },
35
+ {
36
+ name: 'OpenCode',
37
+ value: 'opencode',
38
+ installed: installedTargets.includes('opencode'),
39
+ },
40
+ {
41
+ name: 'Cursor',
42
+ value: 'cursor',
43
+ installed: installedTargets.includes('cursor'),
44
+ },
45
+ ];
46
+ }
47
+
48
+ /**
49
+ * Format target choice for display in prompts
50
+ * @param target - Target choice to format
51
+ * @param context - Context where choice is displayed (affects status message)
52
+ * @returns Formatted string with target name and installation status
53
+ */
54
+ export function formatTargetChoice(target: TargetChoice, context: 'execution' | 'settings'): string {
55
+ const status = target.installed
56
+ ? chalk.green(' ✓ installed')
57
+ : context === 'execution'
58
+ ? chalk.dim(' (will auto-install)')
59
+ : chalk.dim(' (not installed - will auto-install on first use)');
60
+
61
+ return `${target.name}${status}`;
62
+ }
63
+
64
+ /**
65
+ * Prompt user to select a target
66
+ * @param installedTargets - List of currently installed target IDs
67
+ * @param message - Prompt message to display
68
+ * @param context - Context where prompt is shown (affects status formatting)
69
+ * @returns Selected target ID
70
+ * @throws {UserCancelledError} If user cancels the prompt
71
+ */
72
+ export async function promptForTargetSelection(
73
+ installedTargets: string[],
74
+ message: string,
75
+ context: 'execution' | 'settings'
76
+ ): Promise<string> {
77
+ const availableTargets = buildAvailableTargets(installedTargets);
78
+
79
+ try {
80
+ const { targetId } = await inquirer.prompt([
81
+ {
82
+ type: 'list',
83
+ name: 'targetId',
84
+ message,
85
+ choices: availableTargets.map((target) => ({
86
+ name: formatTargetChoice(target, context),
87
+ value: target.value,
88
+ })),
89
+ },
90
+ ]);
91
+
92
+ return targetId;
93
+ } catch (error) {
94
+ handlePromptError(error, 'Target selection cancelled');
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Prompt for default target with "Ask me every time" option
100
+ * @param installedTargets - List of currently installed target IDs
101
+ * @param currentDefault - Current default target (if any)
102
+ * @returns Selected default target ID or 'ask-every-time'
103
+ * @throws {UserCancelledError} If user cancels the prompt
104
+ */
105
+ export async function promptForDefaultTarget(
106
+ installedTargets: string[],
107
+ currentDefault?: string
108
+ ): Promise<string> {
109
+ const availableTargets = buildAvailableTargets(installedTargets);
110
+
111
+ try {
112
+ const { defaultTarget } = await inquirer.prompt([
113
+ {
114
+ type: 'list',
115
+ name: 'defaultTarget',
116
+ message: 'Select default target platform:',
117
+ choices: [
118
+ ...availableTargets.map((target) => ({
119
+ name: formatTargetChoice(target, 'settings'),
120
+ value: target.value,
121
+ })),
122
+ new inquirer.Separator(),
123
+ {
124
+ name: 'Ask me every time',
125
+ value: 'ask-every-time',
126
+ },
127
+ ],
128
+ default: currentDefault || 'ask-every-time',
129
+ },
130
+ ]);
131
+
132
+ return defaultTarget;
133
+ } catch (error) {
134
+ handlePromptError(error, 'Target selection cancelled');
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Ensure target is installed, auto-installing if needed
140
+ * @param targetId - Target ID to ensure is installed
141
+ * @param targetInstaller - TargetInstaller instance to use
142
+ * @param installedTargets - List of currently installed target IDs
143
+ * @returns True if target is installed (or successfully installed), false otherwise
144
+ */
145
+ export async function ensureTargetInstalled(
146
+ targetId: string,
147
+ targetInstaller: TargetInstaller,
148
+ installedTargets: string[]
149
+ ): Promise<boolean> {
150
+ const installation = targetInstaller.getInstallationInfo(targetId);
151
+
152
+ // Already installed - nothing to do
153
+ if (installedTargets.includes(targetId)) {
154
+ return true;
155
+ }
156
+
157
+ // Not installed - try to install
158
+ console.log();
159
+ const installed = await targetInstaller.install(targetId, true);
160
+
161
+ if (!installed) {
162
+ console.log(chalk.red(`\n✗ Failed to install ${installation?.name}`));
163
+ console.log(chalk.yellow(' Please install manually and try again.\n'));
164
+ return false;
165
+ }
166
+
167
+ console.log();
168
+ return true;
169
+ }