@sylphx/flow 1.6.13 → 1.8.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.
@@ -47,6 +47,10 @@ interface SyncManifest {
47
47
  inRegistry: string[];
48
48
  notInRegistry: string[];
49
49
  };
50
+ hooks: {
51
+ inConfig: string[]; // Hooks from config (will be synced)
52
+ orphaned: string[]; // Hooks that exist locally but not in config (ask user)
53
+ };
50
54
  preserve: string[];
51
55
  }
52
56
 
@@ -82,6 +86,7 @@ export async function buildSyncManifest(cwd: string, target: Target): Promise<Sy
82
86
  slashCommands: { inFlow: [], unknown: [], missing: [] },
83
87
  rules: { inFlow: [], unknown: [], missing: [] },
84
88
  mcpServers: { inRegistry: [], notInRegistry: [] },
89
+ hooks: { inConfig: [], orphaned: [] },
85
90
  preserve: [],
86
91
  };
87
92
 
@@ -158,6 +163,34 @@ export async function buildSyncManifest(cwd: string, target: Target): Promise<Sy
158
163
  }
159
164
  }
160
165
 
166
+ // Hooks - detect orphaned hooks (only for targets that support hooks)
167
+ if (target.setupHooks) {
168
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
169
+ if (fs.existsSync(settingsPath)) {
170
+ try {
171
+ const content = await fs.promises.readFile(settingsPath, 'utf-8');
172
+ const settings = JSON.parse(content);
173
+
174
+ if (settings.hooks) {
175
+ const existingHookTypes = Object.keys(settings.hooks);
176
+
177
+ // Expected hooks from config (currently only Notification)
178
+ // In the future, this could be read from Flow config
179
+ const expectedHookTypes = ['Notification'];
180
+
181
+ manifest.hooks.inConfig = existingHookTypes.filter(type =>
182
+ expectedHookTypes.includes(type)
183
+ );
184
+ manifest.hooks.orphaned = existingHookTypes.filter(type =>
185
+ !expectedHookTypes.includes(type)
186
+ );
187
+ }
188
+ } catch (error) {
189
+ console.warn(chalk.yellow('⚠ Failed to read settings.json'));
190
+ }
191
+ }
192
+ }
193
+
161
194
  // Files to preserve
162
195
  manifest.preserve = [
163
196
  '.sylphx-flow/',
@@ -173,7 +206,7 @@ export async function buildSyncManifest(cwd: string, target: Target): Promise<Sy
173
206
  /**
174
207
  * Show sync preview with categorization
175
208
  */
176
- export function showSyncPreview(manifest: SyncManifest, cwd: string): void {
209
+ export function showSyncPreview(manifest: SyncManifest, cwd: string, target?: Target): void {
177
210
  console.log(chalk.cyan.bold('━━━ 🔄 Sync Preview\n'));
178
211
 
179
212
  // Will sync section
@@ -183,7 +216,9 @@ export function showSyncPreview(manifest: SyncManifest, cwd: string): void {
183
216
  manifest.rules.inFlow.length > 0 ||
184
217
  manifest.mcpServers.inRegistry.length > 0;
185
218
 
186
- if (hasFlowFiles) {
219
+ const hasHooksSupport = target?.setupHooks !== undefined;
220
+
221
+ if (hasFlowFiles || hasHooksSupport) {
187
222
  console.log(chalk.green('Will sync (delete + reinstall):\n'));
188
223
 
189
224
  if (manifest.agents.inFlow.length > 0) {
@@ -217,6 +252,20 @@ export function showSyncPreview(manifest: SyncManifest, cwd: string): void {
217
252
  });
218
253
  console.log('');
219
254
  }
255
+
256
+ // Show hooks if target supports them
257
+ if (hasHooksSupport) {
258
+ console.log(chalk.dim(' Settings:'));
259
+
260
+ if (manifest.hooks.inConfig.length > 0) {
261
+ manifest.hooks.inConfig.forEach((hookType) => {
262
+ console.log(chalk.dim(` ✓ ${hookType} hook`));
263
+ });
264
+ } else {
265
+ console.log(chalk.dim(` ✓ Hooks configuration`));
266
+ }
267
+ console.log('');
268
+ }
220
269
  }
221
270
 
222
271
  // Missing templates section (will be installed)
@@ -258,7 +307,8 @@ export function showSyncPreview(manifest: SyncManifest, cwd: string): void {
258
307
  manifest.agents.unknown.length > 0 ||
259
308
  manifest.slashCommands.unknown.length > 0 ||
260
309
  manifest.rules.unknown.length > 0 ||
261
- manifest.mcpServers.notInRegistry.length > 0;
310
+ manifest.mcpServers.notInRegistry.length > 0 ||
311
+ manifest.hooks.orphaned.length > 0;
262
312
 
263
313
  if (hasUnknownFiles) {
264
314
  console.log(chalk.yellow('Unknown files (not in Flow templates):\n'));
@@ -294,6 +344,14 @@ export function showSyncPreview(manifest: SyncManifest, cwd: string): void {
294
344
  });
295
345
  console.log('');
296
346
  }
347
+
348
+ if (manifest.hooks.orphaned.length > 0) {
349
+ console.log(chalk.dim(' Hooks:'));
350
+ manifest.hooks.orphaned.forEach((hookType) => {
351
+ console.log(chalk.dim(` ? ${hookType} hook`));
352
+ });
353
+ console.log('');
354
+ }
297
355
  } else {
298
356
  console.log(chalk.green('✓ No unknown files\n'));
299
357
  }
@@ -309,10 +367,19 @@ export function showSyncPreview(manifest: SyncManifest, cwd: string): void {
309
367
  console.log('');
310
368
  }
311
369
 
370
+ /**
371
+ * Selected items to remove with type information
372
+ */
373
+ export interface SelectedToRemove {
374
+ files: string[];
375
+ mcpServers: string[];
376
+ hooks: string[];
377
+ }
378
+
312
379
  /**
313
380
  * Select unknown files to remove
314
381
  */
315
- export async function selectUnknownFilesToRemove(manifest: SyncManifest): Promise<string[]> {
382
+ export async function selectUnknownFilesToRemove(manifest: SyncManifest): Promise<SelectedToRemove> {
316
383
  const unknownFiles: Array<{ name: string; value: string; type: string }> = [];
317
384
 
318
385
  // Collect all unknown files
@@ -348,8 +415,16 @@ export async function selectUnknownFilesToRemove(manifest: SyncManifest): Promis
348
415
  });
349
416
  });
350
417
 
418
+ manifest.hooks.orphaned.forEach((hookType) => {
419
+ unknownFiles.push({
420
+ name: `Hook: ${hookType}`,
421
+ value: hookType,
422
+ type: 'hook',
423
+ });
424
+ });
425
+
351
426
  if (unknownFiles.length === 0) {
352
- return [];
427
+ return { files: [], mcpServers: [], hooks: [] };
353
428
  }
354
429
 
355
430
  const { default: inquirer } = await import('inquirer');
@@ -362,7 +437,27 @@ export async function selectUnknownFilesToRemove(manifest: SyncManifest): Promis
362
437
  },
363
438
  ]);
364
439
 
365
- return selected;
440
+ // Categorize selected items by type
441
+ const selectedSet = new Set(selected);
442
+ const result: SelectedToRemove = {
443
+ files: [],
444
+ mcpServers: [],
445
+ hooks: [],
446
+ };
447
+
448
+ for (const item of unknownFiles) {
449
+ if (selectedSet.has(item.value)) {
450
+ if (item.type === 'mcp') {
451
+ result.mcpServers.push(item.value);
452
+ } else if (item.type === 'hook') {
453
+ result.hooks.push(item.value);
454
+ } else {
455
+ result.files.push(item.value);
456
+ }
457
+ }
458
+ }
459
+
460
+ return result;
366
461
  }
367
462
 
368
463
  /**
@@ -370,7 +465,7 @@ export async function selectUnknownFilesToRemove(manifest: SyncManifest): Promis
370
465
  */
371
466
  export function showFinalSummary(
372
467
  manifest: SyncManifest,
373
- selectedUnknowns: string[]
468
+ selectedUnknowns: SelectedToRemove
374
469
  ): void {
375
470
  console.log(chalk.cyan.bold('\n━━━ 📋 Final Summary\n'));
376
471
 
@@ -395,22 +490,30 @@ export function showFinalSummary(
395
490
  }
396
491
 
397
492
  // Will remove (selected unknowns)
398
- if (selectedUnknowns.length > 0) {
493
+ const totalToRemove = selectedUnknowns.files.length + selectedUnknowns.mcpServers.length + selectedUnknowns.hooks.length;
494
+ if (totalToRemove > 0) {
399
495
  console.log(chalk.red('Remove (selected):\n'));
400
- selectedUnknowns.forEach((file) => {
401
- const name = file.includes('/') ? path.basename(file) : file;
402
- console.log(chalk.dim(` - ${name}`));
496
+ selectedUnknowns.files.forEach((file) => {
497
+ console.log(chalk.dim(` - ${path.basename(file)}`));
498
+ });
499
+ selectedUnknowns.mcpServers.forEach((server) => {
500
+ console.log(chalk.dim(` - MCP: ${server}`));
501
+ });
502
+ selectedUnknowns.hooks.forEach((hook) => {
503
+ console.log(chalk.dim(` - Hook: ${hook}`));
403
504
  });
404
505
  console.log('');
405
506
  }
406
507
 
407
508
  // Will preserve
509
+ const allSelected = [...selectedUnknowns.files, ...selectedUnknowns.mcpServers, ...selectedUnknowns.hooks];
408
510
  const preservedUnknowns = [
409
511
  ...manifest.agents.unknown,
410
512
  ...manifest.slashCommands.unknown,
411
513
  ...manifest.rules.unknown,
412
514
  ...manifest.mcpServers.notInRegistry,
413
- ].filter((file) => !selectedUnknowns.includes(file));
515
+ ...manifest.hooks.orphaned,
516
+ ].filter((file) => !allSelected.includes(file));
414
517
 
415
518
  if (preservedUnknowns.length > 0) {
416
519
  console.log(chalk.green('Preserve:\n'));
@@ -427,7 +530,7 @@ export function showFinalSummary(
427
530
  */
428
531
  export async function executeSyncDelete(
429
532
  manifest: SyncManifest,
430
- selectedUnknowns: string[]
533
+ selectedUnknowns: SelectedToRemove
431
534
  ): Promise<{ templates: number; unknowns: number }> {
432
535
  const flowFiles = [
433
536
  ...manifest.agents.inFlow,
@@ -454,10 +557,7 @@ export async function executeSyncDelete(
454
557
  }
455
558
 
456
559
  // Delete selected unknown files
457
- for (const file of selectedUnknowns) {
458
- // Skip MCP servers (handled separately)
459
- if (!file.includes('/')) continue;
460
-
560
+ for (const file of selectedUnknowns.files) {
461
561
  try {
462
562
  await fs.promises.unlink(file);
463
563
  console.log(chalk.dim(` ✓ Deleted: ${path.basename(file)}`));
@@ -530,3 +630,44 @@ export async function removeMCPServers(cwd: string, serversToRemove: string[]):
530
630
  return 0;
531
631
  }
532
632
  }
633
+
634
+ /**
635
+ * Remove hooks from .claude/settings.json
636
+ */
637
+ export async function removeHooks(cwd: string, hooksToRemove: string[]): Promise<number> {
638
+ if (hooksToRemove.length === 0) {
639
+ return 0;
640
+ }
641
+
642
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
643
+
644
+ try {
645
+ const content = await fs.promises.readFile(settingsPath, 'utf-8');
646
+ const settings = JSON.parse(content);
647
+
648
+ if (!settings.hooks) {
649
+ return 0;
650
+ }
651
+
652
+ let removedCount = 0;
653
+ for (const hookType of hooksToRemove) {
654
+ if (settings.hooks[hookType]) {
655
+ delete settings.hooks[hookType];
656
+ console.log(chalk.dim(` ✓ Removed Hook: ${hookType}`));
657
+ removedCount++;
658
+ }
659
+ }
660
+
661
+ // Write back
662
+ await fs.promises.writeFile(
663
+ settingsPath,
664
+ JSON.stringify(settings, null, 2) + '\n',
665
+ 'utf-8'
666
+ );
667
+
668
+ return removedCount;
669
+ } catch (error) {
670
+ console.warn(chalk.yellow('⚠ Failed to update settings.json'));
671
+ return 0;
672
+ }
673
+ }