@sylphx/flow 2.0.0 → 2.1.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.
@@ -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
+ }