@sylphx/flow 1.6.12 → 1.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @sylphx/flow
2
2
 
3
+ ## 1.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add orphaned hooks detection and removal to sync command
8
+
9
+ The sync command now properly detects and prompts for removal of hooks that exist locally but are not in the configuration. This ensures full synchronization between local settings and the Flow configuration.
10
+
11
+ **New Features:**
12
+
13
+ - Detects orphaned hooks in `.claude/settings.json`
14
+ - Shows orphaned hooks in sync preview
15
+ - Allows users to select which orphaned hooks to remove
16
+ - Properly cleans up settings.json after removal
17
+
18
+ **Breaking Changes:**
19
+
20
+ - Internal API: `selectUnknownFilesToRemove()` now returns `SelectedToRemove` object instead of `string[]`
21
+
22
+ ## 1.6.13
23
+
24
+ ### Patch Changes
25
+
26
+ - 746d576: Fix missing chalk import in claude-code target causing ReferenceError in dry-run mode
27
+ - ea6aa39: fix(sync): display hooks configuration in sync preview
28
+
29
+ When running `sylphx-flow --sync`, the sync preview now shows that hooks will be configured/updated. This makes it clear to users that hook settings are being synchronized along with other Flow templates.
30
+
31
+ Previously, hooks were being updated during sync but this wasn't visible in the sync preview output, leading to confusion about whether hooks were being synced.
32
+
33
+ - 6ea9757: Test repository link in Slack notification
34
+
3
35
  ## 1.6.12
4
36
 
5
37
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/flow",
3
- "version": "1.6.12",
3
+ "version": "1.7.0",
4
4
  "description": "AI-powered development workflow automation with autonomous loop mode and smart configuration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -380,7 +380,7 @@ async function executeSetupPhase(prompt: string | undefined, options: FlowOption
380
380
 
381
381
  // Handle sync mode - delete template files first
382
382
  if (options.sync && !options.dryRun) {
383
- const { buildSyncManifest, showSyncPreview, selectUnknownFilesToRemove, showFinalSummary, confirmSync, executeSyncDelete, removeMCPServers } = await import('../utils/sync-utils.js');
383
+ const { buildSyncManifest, showSyncPreview, selectUnknownFilesToRemove, showFinalSummary, confirmSync, executeSyncDelete, removeMCPServers, removeHooks } = await import('../utils/sync-utils.js');
384
384
 
385
385
  // Need target to build manifest
386
386
  const targetId = await selectAndValidateTarget(initOptions);
@@ -396,7 +396,7 @@ async function executeSetupPhase(prompt: string | undefined, options: FlowOption
396
396
 
397
397
  // Show preview
398
398
  console.log(chalk.cyan.bold('━━━ 🔄 Synchronizing Files\n'));
399
- showSyncPreview(manifest, process.cwd());
399
+ showSyncPreview(manifest, process.cwd(), target);
400
400
 
401
401
  // Select unknown files to remove
402
402
  const selectedUnknowns = await selectUnknownFilesToRemove(manifest);
@@ -415,20 +415,27 @@ async function executeSetupPhase(prompt: string | undefined, options: FlowOption
415
415
  const { templates, unknowns } = await executeSyncDelete(manifest, selectedUnknowns);
416
416
 
417
417
  // Remove MCP servers
418
- const mcpServersToRemove = selectedUnknowns.filter(s => !s.includes('/'));
419
418
  let mcpRemoved = 0;
420
- if (mcpServersToRemove.length > 0) {
421
- mcpRemoved = await removeMCPServers(process.cwd(), mcpServersToRemove);
419
+ if (selectedUnknowns.mcpServers.length > 0) {
420
+ mcpRemoved = await removeMCPServers(process.cwd(), selectedUnknowns.mcpServers);
421
+ }
422
+
423
+ // Remove hooks
424
+ let hooksRemoved = 0;
425
+ if (selectedUnknowns.hooks.length > 0) {
426
+ hooksRemoved = await removeHooks(process.cwd(), selectedUnknowns.hooks);
422
427
  }
423
428
 
424
429
  // Summary
425
430
  console.log(chalk.green(`\n✓ Synced ${templates} templates`));
426
- if (unknowns > 0 || mcpRemoved > 0) {
427
- console.log(chalk.green(`✓ Removed ${unknowns + mcpRemoved} files`));
431
+ const totalRemoved = unknowns + mcpRemoved + hooksRemoved;
432
+ if (totalRemoved > 0) {
433
+ console.log(chalk.green(`✓ Removed ${totalRemoved} items`));
428
434
  }
429
- const preserved = manifest.agents.unknown.length + manifest.slashCommands.unknown.length + manifest.rules.unknown.length + manifest.mcpServers.notInRegistry.length - selectedUnknowns.length;
435
+ const totalSelected = selectedUnknowns.files.length + selectedUnknowns.mcpServers.length + selectedUnknowns.hooks.length;
436
+ const preserved = manifest.agents.unknown.length + manifest.slashCommands.unknown.length + manifest.rules.unknown.length + manifest.mcpServers.notInRegistry.length + manifest.hooks.orphaned.length - totalSelected;
430
437
  if (preserved > 0) {
431
- console.log(chalk.green(`✓ Preserved ${preserved} custom files`));
438
+ console.log(chalk.green(`✓ Preserved ${preserved} custom items`));
432
439
  }
433
440
  console.log('');
434
441
  } else if (!options.sync) {
@@ -725,7 +732,7 @@ async function executeFlowOnce(prompt: string | undefined, options: FlowOptions)
725
732
 
726
733
  // Handle sync mode - delete template files first
727
734
  if (options.sync && !options.dryRun) {
728
- const { buildSyncManifest, showSyncPreview, selectUnknownFilesToRemove, showFinalSummary, confirmSync, executeSyncDelete, removeMCPServers } = await import('../utils/sync-utils.js');
735
+ const { buildSyncManifest, showSyncPreview, selectUnknownFilesToRemove, showFinalSummary, confirmSync, executeSyncDelete, removeMCPServers, removeHooks } = await import('../utils/sync-utils.js');
729
736
 
730
737
  // Need target to build manifest
731
738
  const targetId = await selectAndValidateTarget(initOptions);
@@ -741,7 +748,7 @@ async function executeFlowOnce(prompt: string | undefined, options: FlowOptions)
741
748
 
742
749
  // Show preview
743
750
  console.log(chalk.cyan.bold('━━━ 🔄 Synchronizing Files\n'));
744
- showSyncPreview(manifest, process.cwd());
751
+ showSyncPreview(manifest, process.cwd(), target);
745
752
 
746
753
  // Select unknown files to remove
747
754
  const selectedUnknowns = await selectUnknownFilesToRemove(manifest);
@@ -760,20 +767,27 @@ async function executeFlowOnce(prompt: string | undefined, options: FlowOptions)
760
767
  const { templates, unknowns } = await executeSyncDelete(manifest, selectedUnknowns);
761
768
 
762
769
  // Remove MCP servers
763
- const mcpServersToRemove = selectedUnknowns.filter(s => !s.includes('/'));
764
770
  let mcpRemoved = 0;
765
- if (mcpServersToRemove.length > 0) {
766
- mcpRemoved = await removeMCPServers(process.cwd(), mcpServersToRemove);
771
+ if (selectedUnknowns.mcpServers.length > 0) {
772
+ mcpRemoved = await removeMCPServers(process.cwd(), selectedUnknowns.mcpServers);
773
+ }
774
+
775
+ // Remove hooks
776
+ let hooksRemoved = 0;
777
+ if (selectedUnknowns.hooks.length > 0) {
778
+ hooksRemoved = await removeHooks(process.cwd(), selectedUnknowns.hooks);
767
779
  }
768
780
 
769
781
  // Summary
770
782
  console.log(chalk.green(`\n✓ Synced ${templates} templates`));
771
- if (unknowns > 0 || mcpRemoved > 0) {
772
- console.log(chalk.green(`✓ Removed ${unknowns + mcpRemoved} files`));
783
+ const totalRemoved = unknowns + mcpRemoved + hooksRemoved;
784
+ if (totalRemoved > 0) {
785
+ console.log(chalk.green(`✓ Removed ${totalRemoved} items`));
773
786
  }
774
- const preserved = manifest.agents.unknown.length + manifest.slashCommands.unknown.length + manifest.rules.unknown.length + manifest.mcpServers.notInRegistry.length - selectedUnknowns.length;
787
+ const totalSelected = selectedUnknowns.files.length + selectedUnknowns.mcpServers.length + selectedUnknowns.hooks.length;
788
+ const preserved = manifest.agents.unknown.length + manifest.slashCommands.unknown.length + manifest.rules.unknown.length + manifest.mcpServers.notInRegistry.length + manifest.hooks.orphaned.length - totalSelected;
775
789
  if (preserved > 0) {
776
- console.log(chalk.green(`✓ Preserved ${preserved} custom files`));
790
+ console.log(chalk.green(`✓ Preserved ${preserved} custom items`));
777
791
  }
778
792
  console.log('');
779
793
  } else {
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import fsPromises from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import chalk from 'chalk';
5
6
  import { FileInstaller } from '../core/installers/file-installer.js';
6
7
  import { MCPInstaller } from '../core/installers/mcp-installer.js';
7
8
  import type { AgentMetadata } from '../types/target-config.types.js';
@@ -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
+ }