@sylphx/flow 1.8.2 → 2.0.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.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Attach Manager
3
+ * Handles merging Flow templates into user's project environment
4
+ * Strategy: Direct override with backup, restore on cleanup
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { existsSync } from 'node:fs';
10
+ import chalk from 'chalk';
11
+ import { ProjectManager } from './project-manager.js';
12
+ import type { BackupManifest } from './backup-manager.js';
13
+ import { GlobalConfigService } from '../services/global-config.js';
14
+ import { MCP_SERVER_REGISTRY } from '../config/servers.js';
15
+
16
+ export interface AttachResult {
17
+ agentsAdded: string[];
18
+ agentsOverridden: string[];
19
+ commandsAdded: string[];
20
+ commandsOverridden: string[];
21
+ rulesAppended: boolean;
22
+ mcpServersAdded: string[];
23
+ mcpServersOverridden: string[];
24
+ singleFilesMerged: string[];
25
+ hooksAdded: string[];
26
+ hooksOverridden: string[];
27
+ conflicts: ConflictInfo[];
28
+ }
29
+
30
+ export interface ConflictInfo {
31
+ type: 'agent' | 'command' | 'mcp' | 'hook';
32
+ name: string;
33
+ action: 'overridden' | 'merged';
34
+ message: string;
35
+ }
36
+
37
+ export interface FlowTemplates {
38
+ agents: Array<{ name: string; content: string }>;
39
+ commands: Array<{ name: string; content: string }>;
40
+ rules?: string;
41
+ mcpServers: Array<{ name: string; config: any }>;
42
+ hooks: Array<{ name: string; content: string }>;
43
+ singleFiles: Array<{ path: string; content: string }>;
44
+ }
45
+
46
+ export class AttachManager {
47
+ private projectManager: ProjectManager;
48
+ private configService: GlobalConfigService;
49
+
50
+ constructor(projectManager: ProjectManager) {
51
+ this.projectManager = projectManager;
52
+ this.configService = new GlobalConfigService();
53
+ }
54
+
55
+ /**
56
+ * Get target-specific directory names
57
+ */
58
+ private getTargetDirs(target: 'claude-code' | 'opencode'): {
59
+ agents: string;
60
+ commands: string;
61
+ } {
62
+ return target === 'claude-code'
63
+ ? { agents: 'agents', commands: 'commands' }
64
+ : { agents: 'agent', commands: 'command' };
65
+ }
66
+
67
+ /**
68
+ * Load global MCP servers from ~/.sylphx-flow/mcp-config.json
69
+ */
70
+ private async loadGlobalMCPServers(
71
+ target: 'claude-code' | 'opencode'
72
+ ): Promise<Array<{ name: string; config: any }>> {
73
+ try {
74
+ const enabledServers = await this.configService.getEnabledMCPServers();
75
+ const servers: Array<{ name: string; config: any }> = [];
76
+
77
+ for (const [serverKey, serverConfig] of Object.entries(enabledServers)) {
78
+ // Lookup server definition in registry
79
+ const serverDef = MCP_SERVER_REGISTRY[serverKey];
80
+
81
+ if (!serverDef) {
82
+ console.warn(`MCP server '${serverKey}' not found in registry, skipping`);
83
+ continue;
84
+ }
85
+
86
+ // Clone the server config from registry
87
+ let config: any = { ...serverDef.config };
88
+
89
+ // Merge environment variables from global config
90
+ if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
91
+ if (config.type === 'stdio' || config.type === 'local') {
92
+ config.env = { ...config.env, ...serverConfig.env };
93
+ }
94
+ }
95
+
96
+ servers.push({ name: serverDef.name, config });
97
+ }
98
+
99
+ return servers;
100
+ } catch (error) {
101
+ // If global config doesn't exist or fails to load, return empty array
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Attach Flow templates to project
108
+ * Strategy: Override with warning, backup handles restoration
109
+ */
110
+ async attach(
111
+ projectPath: string,
112
+ projectHash: string,
113
+ target: 'claude-code' | 'opencode',
114
+ templates: FlowTemplates,
115
+ manifest: BackupManifest
116
+ ): Promise<AttachResult> {
117
+ const targetDir = this.projectManager.getTargetConfigDir(projectPath, target);
118
+
119
+ const result: AttachResult = {
120
+ agentsAdded: [],
121
+ agentsOverridden: [],
122
+ commandsAdded: [],
123
+ commandsOverridden: [],
124
+ rulesAppended: false,
125
+ mcpServersAdded: [],
126
+ mcpServersOverridden: [],
127
+ singleFilesMerged: [],
128
+ hooksAdded: [],
129
+ hooksOverridden: [],
130
+ conflicts: [],
131
+ };
132
+
133
+ // Ensure target directory exists
134
+ await fs.mkdir(targetDir, { recursive: true });
135
+
136
+ // 1. Attach agents
137
+ await this.attachAgents(targetDir, target, templates.agents, result, manifest);
138
+
139
+ // 2. Attach commands
140
+ await this.attachCommands(targetDir, target, templates.commands, result, manifest);
141
+
142
+ // 3. Attach rules (if applicable)
143
+ if (templates.rules) {
144
+ await this.attachRules(targetDir, target, templates.rules, result, manifest);
145
+ }
146
+
147
+ // 4. Attach MCP servers (merge global + template servers)
148
+ const globalMCPServers = await this.loadGlobalMCPServers(target);
149
+ const allMCPServers = [...globalMCPServers, ...templates.mcpServers];
150
+
151
+ if (allMCPServers.length > 0) {
152
+ await this.attachMCPServers(
153
+ targetDir,
154
+ target,
155
+ allMCPServers,
156
+ result,
157
+ manifest
158
+ );
159
+ }
160
+
161
+ // 5. Attach hooks
162
+ if (templates.hooks.length > 0) {
163
+ await this.attachHooks(targetDir, templates.hooks, result, manifest);
164
+ }
165
+
166
+ // 6. Attach single files
167
+ if (templates.singleFiles.length > 0) {
168
+ await this.attachSingleFiles(projectPath, templates.singleFiles, result, manifest);
169
+ }
170
+
171
+ // Show conflict warnings
172
+ this.showConflictWarnings(result);
173
+
174
+ return result;
175
+ }
176
+
177
+ /**
178
+ * Attach agents (override strategy)
179
+ */
180
+ private async attachAgents(
181
+ targetDir: string,
182
+ target: 'claude-code' | 'opencode',
183
+ agents: Array<{ name: string; content: string }>,
184
+ result: AttachResult,
185
+ manifest: BackupManifest
186
+ ): Promise<void> {
187
+ const dirs = this.getTargetDirs(target);
188
+ const agentsDir = path.join(targetDir, dirs.agents);
189
+ await fs.mkdir(agentsDir, { recursive: true });
190
+
191
+ for (const agent of agents) {
192
+ const agentPath = path.join(agentsDir, agent.name);
193
+ const existed = existsSync(agentPath);
194
+
195
+ if (existed) {
196
+ // Conflict: user has same agent
197
+ result.agentsOverridden.push(agent.name);
198
+ result.conflicts.push({
199
+ type: 'agent',
200
+ name: agent.name,
201
+ action: 'overridden',
202
+ message: `Agent '${agent.name}' overridden (will be restored on exit)`,
203
+ });
204
+
205
+ // Track in manifest
206
+ manifest.backup.agents.user.push(agent.name);
207
+ } else {
208
+ result.agentsAdded.push(agent.name);
209
+ }
210
+
211
+ // Write Flow agent (override)
212
+ await fs.writeFile(agentPath, agent.content);
213
+
214
+ // Track Flow agent
215
+ manifest.backup.agents.flow.push(agent.name);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Attach commands (override strategy)
221
+ */
222
+ private async attachCommands(
223
+ targetDir: string,
224
+ target: 'claude-code' | 'opencode',
225
+ commands: Array<{ name: string; content: string }>,
226
+ result: AttachResult,
227
+ manifest: BackupManifest
228
+ ): Promise<void> {
229
+ const dirs = this.getTargetDirs(target);
230
+ const commandsDir = path.join(targetDir, dirs.commands);
231
+ await fs.mkdir(commandsDir, { recursive: true });
232
+
233
+ for (const command of commands) {
234
+ const commandPath = path.join(commandsDir, command.name);
235
+ const existed = existsSync(commandPath);
236
+
237
+ if (existed) {
238
+ // Conflict: user has same command
239
+ result.commandsOverridden.push(command.name);
240
+ result.conflicts.push({
241
+ type: 'command',
242
+ name: command.name,
243
+ action: 'overridden',
244
+ message: `Command '${command.name}' overridden (will be restored on exit)`,
245
+ });
246
+
247
+ // Track in manifest
248
+ manifest.backup.commands.user.push(command.name);
249
+ } else {
250
+ result.commandsAdded.push(command.name);
251
+ }
252
+
253
+ // Write Flow command (override)
254
+ await fs.writeFile(commandPath, command.content);
255
+
256
+ // Track Flow command
257
+ manifest.backup.commands.flow.push(command.name);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Attach rules (append strategy for AGENTS.md)
263
+ */
264
+ private async attachRules(
265
+ targetDir: string,
266
+ target: 'claude-code' | 'opencode',
267
+ rules: string,
268
+ result: AttachResult,
269
+ manifest: BackupManifest
270
+ ): Promise<void> {
271
+ // Claude Code: .claude/agents/AGENTS.md
272
+ // OpenCode: .opencode/AGENTS.md
273
+ const dirs = this.getTargetDirs(target);
274
+ const rulesPath =
275
+ target === 'claude-code'
276
+ ? path.join(targetDir, dirs.agents, 'AGENTS.md')
277
+ : path.join(targetDir, 'AGENTS.md');
278
+
279
+ if (existsSync(rulesPath)) {
280
+ // User has AGENTS.md, append Flow rules
281
+ const userRules = await fs.readFile(rulesPath, 'utf-8');
282
+
283
+ // Check if already appended (avoid duplicates)
284
+ if (userRules.includes('<!-- Sylphx Flow Rules -->')) {
285
+ // Already appended, skip
286
+ return;
287
+ }
288
+
289
+ const merged = `${userRules}
290
+
291
+ <!-- ========== Sylphx Flow Rules (Auto-injected) ========== -->
292
+
293
+ ${rules}
294
+
295
+ <!-- ========== End of Sylphx Flow Rules ========== -->
296
+ `;
297
+
298
+ await fs.writeFile(rulesPath, merged);
299
+
300
+ manifest.backup.rules = {
301
+ path: rulesPath,
302
+ originalSize: userRules.length,
303
+ flowContentAdded: true,
304
+ };
305
+ } else {
306
+ // User doesn't have AGENTS.md, create new
307
+ await fs.mkdir(path.dirname(rulesPath), { recursive: true });
308
+ await fs.writeFile(rulesPath, rules);
309
+
310
+ manifest.backup.rules = {
311
+ path: rulesPath,
312
+ originalSize: 0,
313
+ flowContentAdded: true,
314
+ };
315
+ }
316
+
317
+ result.rulesAppended = true;
318
+ }
319
+
320
+ /**
321
+ * Attach MCP servers (merge strategy)
322
+ */
323
+ private async attachMCPServers(
324
+ targetDir: string,
325
+ target: 'claude-code' | 'opencode',
326
+ mcpServers: Array<{ name: string; config: any }>,
327
+ result: AttachResult,
328
+ manifest: BackupManifest
329
+ ): Promise<void> {
330
+ // Claude Code: .claude/settings.json (mcp.servers)
331
+ // OpenCode: .opencode/.mcp.json
332
+ const configPath =
333
+ target === 'claude-code'
334
+ ? path.join(targetDir, 'settings.json')
335
+ : path.join(targetDir, '.mcp.json');
336
+
337
+ let config: any = {};
338
+
339
+ if (existsSync(configPath)) {
340
+ config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
341
+ }
342
+
343
+ // Ensure mcp.servers exists
344
+ if (!config.mcp) config.mcp = {};
345
+ if (!config.mcp.servers) config.mcp.servers = {};
346
+
347
+ // Add Flow MCP servers
348
+ for (const server of mcpServers) {
349
+ if (config.mcp.servers[server.name]) {
350
+ // Conflict: user has same MCP server
351
+ result.mcpServersOverridden.push(server.name);
352
+ result.conflicts.push({
353
+ type: 'mcp',
354
+ name: server.name,
355
+ action: 'overridden',
356
+ message: `MCP server '${server.name}' overridden (will be restored on exit)`,
357
+ });
358
+ } else {
359
+ result.mcpServersAdded.push(server.name);
360
+ }
361
+
362
+ // Override with Flow config
363
+ config.mcp.servers[server.name] = server.config;
364
+ }
365
+
366
+ // Write updated config
367
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
368
+
369
+ // Track in manifest
370
+ manifest.backup.config = {
371
+ path: configPath,
372
+ hash: '', // TODO: calculate hash
373
+ mcpServersCount: Object.keys(config.mcp.servers).length,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Attach hooks (override strategy)
379
+ */
380
+ private async attachHooks(
381
+ targetDir: string,
382
+ hooks: Array<{ name: string; content: string }>,
383
+ result: AttachResult,
384
+ manifest: BackupManifest
385
+ ): Promise<void> {
386
+ const hooksDir = path.join(targetDir, 'hooks');
387
+ await fs.mkdir(hooksDir, { recursive: true });
388
+
389
+ for (const hook of hooks) {
390
+ const hookPath = path.join(hooksDir, hook.name);
391
+ const existed = existsSync(hookPath);
392
+
393
+ if (existed) {
394
+ result.hooksOverridden.push(hook.name);
395
+ result.conflicts.push({
396
+ type: 'hook',
397
+ name: hook.name,
398
+ action: 'overridden',
399
+ message: `Hook '${hook.name}' overridden (will be restored on exit)`,
400
+ });
401
+ } else {
402
+ result.hooksAdded.push(hook.name);
403
+ }
404
+
405
+ await fs.writeFile(hookPath, hook.content);
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Attach single files (CLAUDE.md, .cursorrules, etc.)
411
+ */
412
+ private async attachSingleFiles(
413
+ projectPath: string,
414
+ singleFiles: Array<{ path: string; content: string }>,
415
+ result: AttachResult,
416
+ manifest: BackupManifest
417
+ ): Promise<void> {
418
+ for (const file of singleFiles) {
419
+ const filePath = path.join(projectPath, file.path);
420
+ const existed = existsSync(filePath);
421
+
422
+ if (existed) {
423
+ // User has file, append Flow content
424
+ const userContent = await fs.readFile(filePath, 'utf-8');
425
+
426
+ // Check if already appended
427
+ if (userContent.includes('<!-- Sylphx Flow Enhancement -->')) {
428
+ continue;
429
+ }
430
+
431
+ const merged = `${userContent}
432
+
433
+ ---
434
+
435
+ **Sylphx Flow Enhancement:**
436
+
437
+ ${file.content}
438
+ `;
439
+
440
+ await fs.writeFile(filePath, merged);
441
+
442
+ manifest.backup.singleFiles[file.path] = {
443
+ existed: true,
444
+ originalSize: userContent.length,
445
+ flowContentAdded: true,
446
+ };
447
+ } else {
448
+ // Create new file
449
+ await fs.writeFile(filePath, file.content);
450
+
451
+ manifest.backup.singleFiles[file.path] = {
452
+ existed: false,
453
+ originalSize: 0,
454
+ flowContentAdded: true,
455
+ };
456
+ }
457
+
458
+ result.singleFilesMerged.push(file.path);
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Show conflict warnings to user
464
+ */
465
+ private showConflictWarnings(result: AttachResult): void {
466
+ if (result.conflicts.length === 0) {
467
+ return;
468
+ }
469
+
470
+ console.log(chalk.yellow('\n⚠️ Conflicts detected:\n'));
471
+
472
+ for (const conflict of result.conflicts) {
473
+ console.log(
474
+ chalk.yellow(` • ${conflict.type}: ${conflict.name} - ${conflict.action}`)
475
+ );
476
+ }
477
+
478
+ console.log(
479
+ chalk.dim('\n Don\'t worry! All overridden content will be restored on exit.\n')
480
+ );
481
+ }
482
+ }