@sylphx/flow 2.1.4 → 2.1.6

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,170 @@
1
+ /**
2
+ * Pure functions for file attachment operations
3
+ * Generic utilities for attaching files with conflict tracking
4
+ */
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ export interface AttachItem {
15
+ name: string;
16
+ content: string;
17
+ }
18
+
19
+ export interface ConflictInfo {
20
+ type: string;
21
+ name: string;
22
+ action: 'overridden' | 'added' | 'skipped';
23
+ message: string;
24
+ }
25
+
26
+ export interface AttachStats {
27
+ added: string[];
28
+ overridden: string[];
29
+ conflicts: ConflictInfo[];
30
+ }
31
+
32
+ export interface ManifestTracker {
33
+ user: string[];
34
+ flow: string[];
35
+ }
36
+
37
+ // ============================================================================
38
+ // Pure Functions
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Create conflict info object
43
+ */
44
+ export const createConflict = (
45
+ type: string,
46
+ name: string,
47
+ action: 'overridden' | 'added' | 'skipped' = 'overridden'
48
+ ): ConflictInfo => ({
49
+ type,
50
+ name,
51
+ action,
52
+ message: `${type.charAt(0).toUpperCase() + type.slice(1)} '${name}' ${action} (will be restored on exit)`,
53
+ });
54
+
55
+ /**
56
+ * Check if file exists at path
57
+ */
58
+ export const fileExists = (filePath: string): boolean => existsSync(filePath);
59
+
60
+ /**
61
+ * Ensure directory exists
62
+ */
63
+ export const ensureDir = (dirPath: string): Promise<void> =>
64
+ fs.mkdir(dirPath, { recursive: true }).then(() => {});
65
+
66
+ /**
67
+ * Write file content
68
+ */
69
+ export const writeFile = (filePath: string, content: string): Promise<void> =>
70
+ fs.writeFile(filePath, content);
71
+
72
+ /**
73
+ * Read file content
74
+ */
75
+ export const readFile = (filePath: string): Promise<string> => fs.readFile(filePath, 'utf-8');
76
+
77
+ // ============================================================================
78
+ // Generic Attach Function
79
+ // ============================================================================
80
+
81
+ /**
82
+ * Attach multiple items to a directory with conflict tracking
83
+ * Pure function that returns stats and manifest updates
84
+ */
85
+ export const attachItemsToDir = async (
86
+ items: AttachItem[],
87
+ targetDir: string,
88
+ itemType: string
89
+ ): Promise<{ stats: AttachStats; manifest: ManifestTracker }> => {
90
+ await ensureDir(targetDir);
91
+
92
+ const stats: AttachStats = {
93
+ added: [],
94
+ overridden: [],
95
+ conflicts: [],
96
+ };
97
+
98
+ const manifest: ManifestTracker = {
99
+ user: [],
100
+ flow: [],
101
+ };
102
+
103
+ for (const item of items) {
104
+ const itemPath = path.join(targetDir, item.name);
105
+ const existed = fileExists(itemPath);
106
+
107
+ if (existed) {
108
+ stats.overridden.push(item.name);
109
+ stats.conflicts.push(createConflict(itemType, item.name, 'overridden'));
110
+ manifest.user.push(item.name);
111
+ } else {
112
+ stats.added.push(item.name);
113
+ }
114
+
115
+ await writeFile(itemPath, item.content);
116
+ manifest.flow.push(item.name);
117
+ }
118
+
119
+ return { stats, manifest };
120
+ };
121
+
122
+ // ============================================================================
123
+ // Rules Attachment (Append Strategy)
124
+ // ============================================================================
125
+
126
+ const FLOW_RULES_START = '<!-- ========== Sylphx Flow Rules (Auto-injected) ========== -->';
127
+ const FLOW_RULES_END = '<!-- ========== End of Sylphx Flow Rules ========== -->';
128
+ const FLOW_RULES_MARKER = '<!-- Sylphx Flow Rules -->';
129
+
130
+ /**
131
+ * Check if content already has Flow rules appended
132
+ */
133
+ export const hasFlowRules = (content: string): boolean => content.includes(FLOW_RULES_MARKER);
134
+
135
+ /**
136
+ * Wrap rules content with markers
137
+ */
138
+ export const wrapRulesContent = (rules: string): string =>
139
+ `\n\n${FLOW_RULES_START}\n\n${rules}\n\n${FLOW_RULES_END}\n`;
140
+
141
+ /**
142
+ * Append rules to existing content
143
+ */
144
+ export const appendRules = (existingContent: string, rules: string): string =>
145
+ existingContent + wrapRulesContent(rules);
146
+
147
+ /**
148
+ * Attach rules file with append strategy
149
+ */
150
+ export const attachRulesFile = async (
151
+ rulesPath: string,
152
+ rules: string
153
+ ): Promise<{ originalSize: number; flowContentAdded: boolean }> => {
154
+ if (fileExists(rulesPath)) {
155
+ const existingContent = await readFile(rulesPath);
156
+
157
+ // Skip if already appended
158
+ if (hasFlowRules(existingContent)) {
159
+ return { originalSize: existingContent.length, flowContentAdded: false };
160
+ }
161
+
162
+ await writeFile(rulesPath, appendRules(existingContent, rules));
163
+ return { originalSize: existingContent.length, flowContentAdded: true };
164
+ }
165
+
166
+ // Create new file
167
+ await ensureDir(path.dirname(rulesPath));
168
+ await writeFile(rulesPath, rules);
169
+ return { originalSize: 0, flowContentAdded: true };
170
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Attach module - Pure functions for file attachment
3
+ */
4
+
5
+ export * from './file-attacher.js';
@@ -9,9 +9,10 @@ import { existsSync } from 'node:fs';
9
9
  import fs from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import chalk from 'chalk';
12
- import { MCP_SERVER_REGISTRY } from '../config/servers.js';
12
+ import { MCP_SERVER_REGISTRY, type MCPServerID } from '../config/servers.js';
13
13
  import { GlobalConfigService } from '../services/global-config.js';
14
14
  import type { Target } from '../types/target.types.js';
15
+ import { attachItemsToDir, attachRulesFile } from './attach/index.js';
15
16
  import type { BackupManifest } from './backup-manager.js';
16
17
  import type { ProjectManager } from './project-manager.js';
17
18
  import { targetManager } from './target-manager.js';
@@ -79,30 +80,33 @@ export class AttachManager {
79
80
 
80
81
  /**
81
82
  * Load global MCP servers from ~/.sylphx-flow/mcp-config.json
83
+ * Uses SSOT: computeEffectiveServers for determining enabled servers
82
84
  */
83
85
  private async loadGlobalMCPServers(
84
86
  _target: Target
85
87
  ): Promise<Array<{ name: string; config: Record<string, unknown> }>> {
86
88
  try {
87
- const enabledServers = await this.configService.getEnabledMCPServers();
89
+ const mcpConfig = await this.configService.loadMCPConfig();
90
+ const enabledServerIds = this.configService.getEnabledServerIds(mcpConfig.servers);
91
+ const effectiveServers = this.configService.getEffectiveMCPServers(mcpConfig.servers);
92
+
88
93
  const servers: Array<{ name: string; config: Record<string, unknown> }> = [];
89
94
 
90
- for (const [serverKey, serverConfig] of Object.entries(enabledServers)) {
91
- // Lookup server definition in registry
92
- const serverDef = MCP_SERVER_REGISTRY[serverKey];
95
+ for (const serverId of enabledServerIds) {
96
+ const serverDef = MCP_SERVER_REGISTRY[serverId as MCPServerID];
97
+ const effective = effectiveServers[serverId as MCPServerID];
93
98
 
94
99
  if (!serverDef) {
95
- console.warn(`MCP server '${serverKey}' not found in registry, skipping`);
96
100
  continue;
97
101
  }
98
102
 
99
103
  // Clone the server config from registry
100
104
  const config: Record<string, unknown> = { ...serverDef.config };
101
105
 
102
- // Merge environment variables from global config
103
- if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
106
+ // Merge environment variables from effective config (SSOT)
107
+ if (effective.env && Object.keys(effective.env).length > 0) {
104
108
  if (config.type === 'stdio' || config.type === 'local') {
105
- config.env = { ...config.env, ...serverConfig.env };
109
+ config.env = { ...config.env, ...effective.env };
106
110
  }
107
111
  }
108
112
 
@@ -188,6 +192,7 @@ export class AttachManager {
188
192
 
189
193
  /**
190
194
  * Attach agents (override strategy)
195
+ * Uses shared attachItemsToDir function
191
196
  */
192
197
  private async attachAgents(
193
198
  projectPath: string,
@@ -196,40 +201,22 @@ export class AttachManager {
196
201
  result: AttachResult,
197
202
  manifest: BackupManifest
198
203
  ): Promise<void> {
199
- // Use full path from target config
200
204
  const agentsDir = path.join(projectPath, target.config.agentDir);
201
- await fs.mkdir(agentsDir, { recursive: true });
202
-
203
- for (const agent of agents) {
204
- const agentPath = path.join(agentsDir, agent.name);
205
- const existed = existsSync(agentPath);
206
-
207
- if (existed) {
208
- // Conflict: user has same agent
209
- result.agentsOverridden.push(agent.name);
210
- result.conflicts.push({
211
- type: 'agent',
212
- name: agent.name,
213
- action: 'overridden',
214
- message: `Agent '${agent.name}' overridden (will be restored on exit)`,
215
- });
216
-
217
- // Track in manifest
218
- manifest.backup.agents.user.push(agent.name);
219
- } else {
220
- result.agentsAdded.push(agent.name);
221
- }
205
+ const { stats, manifest: itemManifest } = await attachItemsToDir(agents, agentsDir, 'agent');
222
206
 
223
- // Write Flow agent (override)
224
- await fs.writeFile(agentPath, agent.content);
207
+ // Update result
208
+ result.agentsAdded.push(...stats.added);
209
+ result.agentsOverridden.push(...stats.overridden);
210
+ result.conflicts.push(...stats.conflicts.map((c) => ({ ...c, type: 'agent' as const })));
225
211
 
226
- // Track Flow agent
227
- manifest.backup.agents.flow.push(agent.name);
228
- }
212
+ // Update manifest
213
+ manifest.backup.agents.user.push(...itemManifest.user);
214
+ manifest.backup.agents.flow.push(...itemManifest.flow);
229
215
  }
230
216
 
231
217
  /**
232
218
  * Attach commands (override strategy)
219
+ * Uses shared attachItemsToDir function
233
220
  */
234
221
  private async attachCommands(
235
222
  projectPath: string,
@@ -238,40 +225,26 @@ export class AttachManager {
238
225
  result: AttachResult,
239
226
  manifest: BackupManifest
240
227
  ): Promise<void> {
241
- // Use full path from target config
242
228
  const commandsDir = path.join(projectPath, target.config.slashCommandsDir);
243
- await fs.mkdir(commandsDir, { recursive: true });
244
-
245
- for (const command of commands) {
246
- const commandPath = path.join(commandsDir, command.name);
247
- const existed = existsSync(commandPath);
248
-
249
- if (existed) {
250
- // Conflict: user has same command
251
- result.commandsOverridden.push(command.name);
252
- result.conflicts.push({
253
- type: 'command',
254
- name: command.name,
255
- action: 'overridden',
256
- message: `Command '${command.name}' overridden (will be restored on exit)`,
257
- });
258
-
259
- // Track in manifest
260
- manifest.backup.commands.user.push(command.name);
261
- } else {
262
- result.commandsAdded.push(command.name);
263
- }
264
-
265
- // Write Flow command (override)
266
- await fs.writeFile(commandPath, command.content);
267
-
268
- // Track Flow command
269
- manifest.backup.commands.flow.push(command.name);
270
- }
229
+ const { stats, manifest: itemManifest } = await attachItemsToDir(
230
+ commands,
231
+ commandsDir,
232
+ 'command'
233
+ );
234
+
235
+ // Update result
236
+ result.commandsAdded.push(...stats.added);
237
+ result.commandsOverridden.push(...stats.overridden);
238
+ result.conflicts.push(...stats.conflicts.map((c) => ({ ...c, type: 'command' as const })));
239
+
240
+ // Update manifest
241
+ manifest.backup.commands.user.push(...itemManifest.user);
242
+ manifest.backup.commands.flow.push(...itemManifest.flow);
271
243
  }
272
244
 
273
245
  /**
274
246
  * Attach rules (append strategy for AGENTS.md)
247
+ * Uses shared attachRulesFile function
275
248
  */
276
249
  private async attachRules(
277
250
  projectPath: string,
@@ -280,52 +253,20 @@ export class AttachManager {
280
253
  result: AttachResult,
281
254
  manifest: BackupManifest
282
255
  ): Promise<void> {
283
- // Use full paths from target config:
284
- // - rulesFile defined (e.g., OpenCode): projectPath/rulesFile
285
- // - rulesFile undefined (e.g., Claude Code): projectPath/agentDir/AGENTS.md
286
256
  const rulesPath = target.config.rulesFile
287
257
  ? path.join(projectPath, target.config.rulesFile)
288
258
  : path.join(projectPath, target.config.agentDir, 'AGENTS.md');
289
259
 
290
- if (existsSync(rulesPath)) {
291
- // User has AGENTS.md, append Flow rules
292
- const userRules = await fs.readFile(rulesPath, 'utf-8');
293
-
294
- // Check if already appended (avoid duplicates)
295
- if (userRules.includes('<!-- Sylphx Flow Rules -->')) {
296
- // Already appended, skip
297
- return;
298
- }
299
-
300
- const merged = `${userRules}
301
-
302
- <!-- ========== Sylphx Flow Rules (Auto-injected) ========== -->
303
-
304
- ${rules}
305
-
306
- <!-- ========== End of Sylphx Flow Rules ========== -->
307
- `;
308
-
309
- await fs.writeFile(rulesPath, merged);
260
+ const { originalSize, flowContentAdded } = await attachRulesFile(rulesPath, rules);
310
261
 
262
+ if (flowContentAdded) {
311
263
  manifest.backup.rules = {
312
264
  path: rulesPath,
313
- originalSize: userRules.length,
314
- flowContentAdded: true,
315
- };
316
- } else {
317
- // User doesn't have AGENTS.md, create new
318
- await fs.mkdir(path.dirname(rulesPath), { recursive: true });
319
- await fs.writeFile(rulesPath, rules);
320
-
321
- manifest.backup.rules = {
322
- path: rulesPath,
323
- originalSize: 0,
265
+ originalSize,
324
266
  flowContentAdded: true,
325
267
  };
268
+ result.rulesAppended = true;
326
269
  }
327
-
328
- result.rulesAppended = true;
329
270
  }
330
271
 
331
272
  /**
@@ -7,10 +7,15 @@ import { existsSync } from 'node:fs';
7
7
  import fs from 'node:fs/promises';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import {
11
+ computeEffectiveServers,
12
+ getEnabledServersFromEffective,
13
+ type MCPServerID,
14
+ } from '../config/servers.js';
10
15
 
11
16
  export interface GlobalSettings {
12
17
  version: string;
13
- defaultTarget?: 'claude-code' | 'opencode' | 'cursor' | 'ask-every-time';
18
+ defaultTarget?: 'claude-code' | 'opencode' | 'ask-every-time';
14
19
  defaultAgent?: string; // Default agent to use (e.g., 'coder', 'writer', 'reviewer', 'orchestrator')
15
20
  firstRun: boolean;
16
21
  lastUpdated: string;
@@ -198,22 +203,35 @@ export class GlobalConfigService {
198
203
  }
199
204
 
200
205
  /**
201
- * Load MCP config
206
+ * Load raw MCP config from file (may be empty if no file)
202
207
  */
203
208
  async loadMCPConfig(): Promise<MCPConfig> {
204
209
  const configPath = this.getMCPConfigPath();
205
210
 
206
211
  if (!existsSync(configPath)) {
207
- return {
208
- version: '1.0.0',
209
- servers: {},
210
- };
212
+ return { version: '1.0.0', servers: {} };
211
213
  }
212
214
 
213
215
  const data = await fs.readFile(configPath, 'utf-8');
214
216
  return JSON.parse(data);
215
217
  }
216
218
 
219
+ /**
220
+ * Get effective MCP servers using SSOT computation
221
+ * Merges saved config with defaults from registry
222
+ */
223
+ getEffectiveMCPServers(savedServers: Record<string, MCPServerConfig> | undefined) {
224
+ return computeEffectiveServers(savedServers);
225
+ }
226
+
227
+ /**
228
+ * Get enabled server IDs using SSOT
229
+ */
230
+ getEnabledServerIds(savedServers: Record<string, MCPServerConfig> | undefined): MCPServerID[] {
231
+ const effective = computeEffectiveServers(savedServers);
232
+ return getEnabledServersFromEffective(effective);
233
+ }
234
+
217
235
  /**
218
236
  * Save MCP config
219
237
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Target Installation Service
3
- * Auto-detects and installs AI CLI tools (Claude Code, OpenCode, Cursor)
3
+ * Auto-detects and installs AI CLI tools (Claude Code, OpenCode)
4
4
  */
5
5
 
6
6
  import { exec } from 'node:child_process';
@@ -14,7 +14,7 @@ import { detectPackageManager, type PackageManager } from '../utils/package-mana
14
14
  const execAsync = promisify(exec);
15
15
 
16
16
  export interface TargetInstallation {
17
- id: 'claude-code' | 'opencode' | 'cursor';
17
+ id: 'claude-code' | 'opencode';
18
18
  name: string;
19
19
  package: string;
20
20
  checkCommand: string;
@@ -61,16 +61,6 @@ const TARGET_INSTALLATIONS: TargetInstallation[] = [
61
61
  }
62
62
  },
63
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
64
  ];
75
65
 
76
66
  export class TargetInstaller {
@@ -160,13 +150,6 @@ export class TargetInstaller {
160
150
  return false;
161
151
  }
162
152
 
163
- // Special handling for Cursor (not npm-installable)
164
- if (targetId === 'cursor') {
165
- console.log(chalk.yellow('\n⚠️ Cursor requires manual installation'));
166
- console.log(chalk.cyan(' Visit https://cursor.sh to download and install\n'));
167
- return false;
168
- }
169
-
170
153
  // Confirm installation unless auto-confirm is enabled
171
154
  if (!autoConfirm) {
172
155
  try {
@@ -1,5 +1,4 @@
1
1
  import { spawn } from 'node:child_process';
2
- import fs from 'node:fs';
3
2
  import fsPromises from 'node:fs/promises';
4
3
  import path from 'node:path';
5
4
  import chalk from 'chalk';
@@ -7,10 +6,16 @@ import { installToDirectory } from '../core/installers/file-installer.js';
7
6
  import { createMCPInstaller } from '../core/installers/mcp-installer.js';
8
7
  import type { AgentMetadata } from '../types/target-config.types.js';
9
8
  import type { CommonOptions, MCPServerConfigUnion, SetupResult, Target } from '../types.js';
10
- import { getAgentsDir, getSlashCommandsDir } from '../utils/config/paths.js';
9
+ import { getAgentsDir } from '../utils/config/paths.js';
11
10
  import { fileUtils, generateHelpText, pathUtils, yamlUtils } from '../utils/config/target-utils.js';
12
11
  import { CLIError } from '../utils/error-handler.js';
13
12
  import { sanitize } from '../utils/security/security.js';
13
+ import {
14
+ detectTargetConfig,
15
+ setupSlashCommandsTo,
16
+ stripFrontMatter,
17
+ transformMCPConfig as transformMCP,
18
+ } from './shared/index.js';
14
19
 
15
20
  /**
16
21
  * Claude Code target - composition approach with all original functionality
@@ -68,50 +73,10 @@ export const claudeCodeTarget: Target = {
68
73
 
69
74
  /**
70
75
  * Transform MCP server configuration for Claude Code
71
- * Convert from various formats to Claude Code's optimal format
76
+ * Uses shared pure function for bidirectional conversion
72
77
  */
73
78
  transformMCPConfig(config: MCPServerConfigUnion, _serverId?: string): Record<string, unknown> {
74
- // Handle legacy OpenCode 'local' type
75
- if (config.type === 'local') {
76
- // Convert OpenCode 'local' array command to Claude Code format
77
- const [command, ...args] = config.command;
78
- return {
79
- type: 'stdio',
80
- command,
81
- ...(args && args.length > 0 && { args }),
82
- ...(config.environment && { env: config.environment }),
83
- };
84
- }
85
-
86
- // Handle new stdio format (already optimized for Claude Code)
87
- if (config.type === 'stdio') {
88
- return {
89
- type: 'stdio',
90
- command: config.command,
91
- ...(config.args && config.args.length > 0 && { args: config.args }),
92
- ...(config.env && { env: config.env }),
93
- };
94
- }
95
-
96
- // Handle legacy OpenCode 'remote' type
97
- if (config.type === 'remote') {
98
- return {
99
- type: 'http',
100
- url: config.url,
101
- ...(config.headers && { headers: config.headers }),
102
- };
103
- }
104
-
105
- // Handle new http format (already optimized for Claude Code)
106
- if (config.type === 'http') {
107
- return {
108
- type: 'http',
109
- url: config.url,
110
- ...(config.headers && { headers: config.headers }),
111
- };
112
- }
113
-
114
- return config;
79
+ return transformMCP(config, 'claude-code');
115
80
  },
116
81
 
117
82
  getConfigPath: (cwd: string) =>
@@ -321,12 +286,7 @@ Please begin your response with a comprehensive summary of all the instructions
321
286
  * Detect if this target is being used in the current environment
322
287
  */
323
288
  detectFromEnvironment(): boolean {
324
- try {
325
- const cwd = process.cwd();
326
- return fs.existsSync(path.join(cwd, '.mcp.json'));
327
- } catch {
328
- return false;
329
- }
289
+ return detectTargetConfig(process.cwd(), '.mcp.json');
330
290
  },
331
291
 
332
292
  /**
@@ -377,9 +337,7 @@ Please begin your response with a comprehensive summary of all the instructions
377
337
  * Transform rules content for Claude Code
378
338
  * Claude Code doesn't need front matter in rules files (CLAUDE.md)
379
339
  */
380
- async transformRulesContent(content: string): Promise<string> {
381
- return yamlUtils.stripFrontMatter(content);
382
- },
340
+ transformRulesContent: stripFrontMatter,
383
341
 
384
342
  /**
385
343
  * Setup hooks for Claude Code
@@ -524,23 +482,7 @@ Please begin your response with a comprehensive summary of all the instructions
524
482
  if (!this.config.slashCommandsDir) {
525
483
  return { count: 0 };
526
484
  }
527
-
528
- const slashCommandsDir = path.join(cwd, this.config.slashCommandsDir);
529
-
530
- const results = await installToDirectory(
531
- getSlashCommandsDir(),
532
- slashCommandsDir,
533
- async (content) => {
534
- // Slash commands are plain markdown with front matter - no transformation needed
535
- return content;
536
- },
537
- {
538
- ...options,
539
- showProgress: false, // UI handled by init-command
540
- }
541
- );
542
-
543
- return { count: results.length };
485
+ return setupSlashCommandsTo(path.join(cwd, this.config.slashCommandsDir), undefined, options);
544
486
  },
545
487
  };
546
488