@sylphx/flow 3.18.0 → 3.19.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -3
  3. package/src/commands/flow/execute-v2.ts +126 -128
  4. package/src/commands/flow-command.ts +52 -42
  5. package/src/config/index.ts +0 -20
  6. package/src/config/targets.ts +1 -1
  7. package/src/core/__tests__/backup-restore.test.ts +1 -1
  8. package/src/core/__tests__/cleanup-handler.test.ts +292 -0
  9. package/src/core/__tests__/git-stash-manager.test.ts +246 -0
  10. package/src/core/__tests__/secrets-manager.test.ts +126 -0
  11. package/src/core/__tests__/session-cleanup.test.ts +147 -0
  12. package/src/core/agent-loader.ts +2 -2
  13. package/src/core/attach-manager.ts +12 -78
  14. package/src/core/backup-manager.ts +8 -20
  15. package/src/core/cleanup-handler.ts +187 -11
  16. package/src/core/flow-executor.ts +139 -126
  17. package/src/core/functional/index.ts +0 -11
  18. package/src/core/git-stash-manager.ts +50 -68
  19. package/src/core/index.ts +1 -1
  20. package/src/core/project-manager.ts +26 -43
  21. package/src/core/secrets-manager.ts +15 -18
  22. package/src/core/session-manager.ts +32 -41
  23. package/src/core/state-detector.ts +4 -15
  24. package/src/core/target-manager.ts +6 -3
  25. package/src/core/target-resolver.ts +14 -9
  26. package/src/core/template-loader.ts +7 -33
  27. package/src/core/upgrade-manager.ts +5 -16
  28. package/src/index.ts +7 -36
  29. package/src/services/auto-upgrade.ts +6 -14
  30. package/src/services/config-service.ts +7 -23
  31. package/src/services/index.ts +1 -1
  32. package/src/targets/claude-code.ts +24 -109
  33. package/src/targets/functional/claude-code-logic.ts +47 -103
  34. package/src/targets/opencode.ts +63 -197
  35. package/src/targets/shared/mcp-transforms.ts +20 -43
  36. package/src/targets/shared/target-operations.ts +1 -54
  37. package/src/types/agent.types.ts +5 -3
  38. package/src/types/mcp.types.ts +38 -1
  39. package/src/types/target.types.ts +4 -24
  40. package/src/types.ts +4 -0
  41. package/src/utils/agent-enhancer.ts +1 -1
  42. package/src/utils/config/target-config.ts +8 -14
  43. package/src/utils/config/target-utils.ts +1 -50
  44. package/src/utils/errors.ts +13 -0
  45. package/src/utils/files/file-operations.ts +16 -0
  46. package/src/utils/files/sync-utils.ts +5 -5
  47. package/src/utils/index.ts +1 -1
  48. package/src/utils/object-utils.ts +10 -2
  49. package/src/utils/security/secret-utils.ts +2 -2
  50. package/src/core/error-handling.ts +0 -512
  51. package/src/core/functional/async.ts +0 -101
  52. package/src/core/functional/either.ts +0 -109
  53. package/src/core/functional/error-handler.ts +0 -135
  54. package/src/core/functional/pipe.ts +0 -189
  55. package/src/core/functional/validation.ts +0 -138
  56. package/src/types/mcp-config.types.ts +0 -448
  57. package/src/utils/error-handler.ts +0 -53
@@ -11,6 +11,7 @@ import {
11
11
  getProjectSettingsFile,
12
12
  USER_SETTINGS_FILE,
13
13
  } from '../config/constants.js';
14
+ import { readJsonFileSafe } from '../utils/files/file-operations.js';
14
15
 
15
16
  /**
16
17
  * User configuration (sensitive data, saved to home directory)
@@ -102,13 +103,8 @@ export class ConfigService {
102
103
  /**
103
104
  * Load user global settings (mainly for API keys)
104
105
  */
105
- static async loadHomeSettings(): Promise<UserSettings> {
106
- try {
107
- const content = await fs.readFile(USER_SETTINGS_FILE, 'utf-8');
108
- return JSON.parse(content);
109
- } catch {
110
- return {};
111
- }
106
+ static loadHomeSettings(): Promise<UserSettings> {
107
+ return readJsonFileSafe<UserSettings>(USER_SETTINGS_FILE, {});
112
108
  }
113
109
 
114
110
  /**
@@ -154,14 +150,8 @@ export class ConfigService {
154
150
  /**
155
151
  * Load project-level settings
156
152
  */
157
- static async loadProjectSettings(cwd: string = process.cwd()): Promise<ProjectSettings> {
158
- try {
159
- const configPath = getProjectSettingsFile(cwd);
160
- const content = await fs.readFile(configPath, 'utf-8');
161
- return JSON.parse(content);
162
- } catch {
163
- return {};
164
- }
153
+ static loadProjectSettings(cwd: string = process.cwd()): Promise<ProjectSettings> {
154
+ return readJsonFileSafe<ProjectSettings>(getProjectSettingsFile(cwd), {});
165
155
  }
166
156
 
167
157
  /**
@@ -186,14 +176,8 @@ export class ConfigService {
186
176
  /**
187
177
  * Load project-local settings (overrides everything)
188
178
  */
189
- static async loadLocalSettings(cwd: string = process.cwd()): Promise<RuntimeChoices> {
190
- try {
191
- const configPath = getProjectLocalSettingsFile(cwd);
192
- const content = await fs.readFile(configPath, 'utf-8');
193
- return JSON.parse(content);
194
- } catch {
195
- return {};
196
- }
179
+ static loadLocalSettings(cwd: string = process.cwd()): Promise<RuntimeChoices> {
180
+ return readJsonFileSafe<RuntimeChoices>(getProjectLocalSettingsFile(cwd), {});
197
181
  }
198
182
 
199
183
  /**
@@ -10,4 +10,4 @@ export {
10
10
  installServers,
11
11
  listAvailableServers,
12
12
  validateServerConfiguration,
13
- } from './mcp-service';
13
+ } from './mcp-service.js';
@@ -2,11 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import fsPromises from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import chalk from 'chalk';
5
- import { installToDirectory } from '../core/installers/file-installer.js';
6
- import { createMCPInstaller } from '../core/installers/mcp-installer.js';
7
5
  import type { AgentMetadata, FrontMatterMetadata } from '../types/target-config.types.js';
8
6
  import type { CommonOptions, MCPServerConfigUnion, SetupResult, Target } from '../types.js';
9
- import { getAgentsDir } from '../utils/config/paths.js';
10
7
  import {
11
8
  type ConfigData,
12
9
  fileUtils,
@@ -14,11 +11,11 @@ import {
14
11
  pathUtils,
15
12
  yamlUtils,
16
13
  } from '../utils/config/target-utils.js';
17
- import { CLIError } from '../utils/error-handler.js';
14
+ import { CLIError } from '../utils/errors.js';
18
15
  import { sanitize } from '../utils/security/security.js';
16
+ import { DEFAULT_CLAUDE_CODE_ENV } from './functional/claude-code-logic.js';
19
17
  import {
20
18
  detectTargetConfig,
21
- setupSlashCommandsTo,
22
19
  stripFrontMatter,
23
20
  transformMCPConfig as transformMCP,
24
21
  } from './shared/index.js';
@@ -40,6 +37,11 @@ interface ProcessExitError extends Error {
40
37
  code: number | null;
41
38
  }
42
39
 
40
+ /** Type guard for Node.js errors with errno/code properties */
41
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
42
+ return error instanceof Error && 'code' in error;
43
+ }
44
+
43
45
  /**
44
46
  * Claude Code target - composition approach with all original functionality
45
47
  */
@@ -70,6 +72,7 @@ export const claudeCodeTarget: Target = {
70
72
  createConfigFile: true,
71
73
  useSecretFiles: false,
72
74
  },
75
+ supportsMCP: true,
73
76
  },
74
77
 
75
78
  /**
@@ -271,7 +274,7 @@ Please begin your response with a comprehensive summary of all the instructions
271
274
  const child = spawn('claude', args, {
272
275
  stdio: 'inherit',
273
276
  shell: false,
274
- env: process.env, // Pass environment variables including ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY
277
+ env: { ...process.env, ...DEFAULT_CLAUDE_CODE_ENV },
275
278
  });
276
279
 
277
280
  child.on('spawn', () => {
@@ -298,17 +301,19 @@ Please begin your response with a comprehensive summary of all the instructions
298
301
  });
299
302
  });
300
303
  } catch (error: unknown) {
301
- const err = error as NodeJS.ErrnoException & { code?: string | number };
302
- if (err.code === 'ENOENT') {
303
- throw new CLIError('Claude Code not found. Please install it first.', 'CLAUDE_NOT_FOUND');
304
+ if (isNodeError(error)) {
305
+ if (error.code === 'ENOENT') {
306
+ throw new CLIError('Claude Code not found. Please install it first.', 'CLAUDE_NOT_FOUND');
307
+ }
308
+ if (error.code !== undefined) {
309
+ throw new CLIError(`Claude Code exited with code ${error.code}`, 'CLAUDE_ERROR');
310
+ }
311
+ throw new CLIError(`Failed to execute Claude Code: ${error.message}`, 'CLAUDE_ERROR');
304
312
  }
305
- if (err.code) {
306
- throw new CLIError(`Claude Code exited with code ${err.code}`, 'CLAUDE_ERROR');
313
+ if (error instanceof Error) {
314
+ throw new CLIError(`Failed to execute Claude Code: ${error.message}`, 'CLAUDE_ERROR');
307
315
  }
308
- throw new CLIError(
309
- `Failed to execute Claude Code: ${(error as Error).message}`,
310
- 'CLAUDE_ERROR'
311
- );
316
+ throw new CLIError(`Failed to execute Claude Code: ${String(error)}`, 'CLAUDE_ERROR');
312
317
  }
313
318
  },
314
319
 
@@ -333,8 +338,7 @@ Please begin your response with a comprehensive summary of all the instructions
333
338
  const content = await fsPromises.readFile(settingsPath, 'utf8');
334
339
  settings = JSON.parse(content);
335
340
  } catch (error: unknown) {
336
- const err = error as NodeJS.ErrnoException;
337
- if (err.code !== 'ENOENT') {
341
+ if (!isNodeError(error) || error.code !== 'ENOENT') {
338
342
  throw error;
339
343
  }
340
344
  // File doesn't exist, will create new
@@ -370,10 +374,10 @@ Please begin your response with a comprehensive summary of all the instructions
370
374
  transformRulesContent: stripFrontMatter,
371
375
 
372
376
  /**
373
- * Setup hooks for Claude Code
374
- * Configure session and prompt hooks for system information display
377
+ * Apply Claude Code settings (attribution, hooks, env, thinking mode)
378
+ * Merges Flow defaults into .claude/settings.json, preserving user settings
375
379
  */
376
- async setupHooks(cwd: string, _options: CommonOptions): Promise<SetupResult> {
380
+ async applySettings(cwd: string, _options: CommonOptions): Promise<SetupResult> {
377
381
  const { processSettings, generateHookCommands } = await import(
378
382
  './functional/claude-code-logic.js'
379
383
  );
@@ -425,95 +429,6 @@ Please begin your response with a comprehensive summary of all the instructions
425
429
  message: 'Configured notification hook',
426
430
  };
427
431
  },
428
-
429
- /**
430
- * Setup agents for Claude Code
431
- * Install agents to .claude/agents/ directory with rules appended
432
- * Output styles are applied dynamically at runtime based on user settings
433
- */
434
- async setupAgents(cwd: string, options: CommonOptions): Promise<SetupResult> {
435
- const { enhanceAgentContent } = await import('../utils/agent-enhancer.js');
436
- const agentsDir = path.join(cwd, this.config.agentDir);
437
-
438
- const results = await installToDirectory(
439
- getAgentsDir(),
440
- agentsDir,
441
- async (content, sourcePath) => {
442
- // Extract rules from ORIGINAL content before transformation
443
- const { metadata } = await yamlUtils.extractFrontMatter(content);
444
- const rules = metadata.rules as string[] | undefined;
445
-
446
- // Transform agent content (converts to Claude Code format, strips unsupported fields)
447
- const transformed = await this.transformAgentContent(content, undefined, sourcePath);
448
-
449
- // Enhance with rules only (output styles are applied dynamically at runtime)
450
- const enhanced = await enhanceAgentContent(transformed, rules, []);
451
-
452
- return enhanced;
453
- },
454
- {
455
- ...options,
456
- showProgress: false, // UI handled by init-command
457
- }
458
- );
459
-
460
- return { count: results.length };
461
- },
462
-
463
- /**
464
- * Setup output styles for Claude Code
465
- * Output styles are appended to each agent file
466
- */
467
- async setupOutputStyles(_cwd: string, _options: CommonOptions): Promise<SetupResult> {
468
- // Output styles are appended to each agent file during setupAgents
469
- // No separate installation needed
470
- return {
471
- count: 0,
472
- message: 'Output styles included in agent files',
473
- };
474
- },
475
-
476
- /**
477
- * Setup rules for Claude Code
478
- * Rules are appended to each agent file
479
- */
480
- async setupRules(_cwd: string, _options: CommonOptions): Promise<SetupResult> {
481
- // Rules are appended to each agent file during setupAgents
482
- // No separate CLAUDE.md file needed
483
- return {
484
- count: 0,
485
- message: 'Rules included in agent files',
486
- };
487
- },
488
-
489
- /**
490
- * Setup MCP servers for Claude Code
491
- * Select, configure, install, and approve MCP servers
492
- */
493
- async setupMCP(cwd: string, options: CommonOptions): Promise<SetupResult> {
494
- const installer = createMCPInstaller(this);
495
- const result = await installer.setupMCP({ ...options, quiet: true });
496
-
497
- // Approve servers in Claude Code settings
498
- if (result.selectedServers.length > 0 && !options.dryRun) {
499
- if (this.approveMCPServers) {
500
- await this.approveMCPServers(cwd, result.selectedServers);
501
- }
502
- }
503
-
504
- return { count: result.selectedServers.length };
505
- },
506
-
507
- /**
508
- * Setup slash commands for Claude Code
509
- * Install slash command templates to .claude/commands/ directory
510
- */
511
- async setupSlashCommands(cwd: string, options: CommonOptions): Promise<SetupResult> {
512
- if (!this.config.slashCommandsDir) {
513
- return { count: 0 };
514
- }
515
- return setupSlashCommandsTo(path.join(cwd, this.config.slashCommandsDir), undefined, options);
516
- },
517
432
  };
518
433
 
519
434
  /**
@@ -12,7 +12,7 @@
12
12
  import type { ConfigError } from '../../core/functional/error-types.js';
13
13
  import { configError } from '../../core/functional/error-types.js';
14
14
  import type { Result } from '../../core/functional/result.js';
15
- import { failure, success, tryCatch } from '../../core/functional/result.js';
15
+ import { success, tryCatch } from '../../core/functional/result.js';
16
16
 
17
17
  /**
18
18
  * Claude Code settings structure
@@ -44,14 +44,19 @@ export interface ClaudeCodeSettings {
44
44
  [key: string]: unknown;
45
45
  }
46
46
 
47
+ /**
48
+ * Default environment variables injected into the Claude Code process
49
+ */
50
+ export const DEFAULT_CLAUDE_CODE_ENV: Record<string, string> = {
51
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: '128000',
52
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
53
+ };
54
+
47
55
  /**
48
56
  * Default Claude Code settings for optimal experience
49
57
  */
50
- export const DEFAULT_CLAUDE_CODE_SETTINGS: Partial<ClaudeCodeSettings> = {
51
- env: {
52
- CLAUDE_CODE_MAX_OUTPUT_TOKENS: '128000',
53
- CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
54
- },
58
+ const DEFAULT_CLAUDE_CODE_SETTINGS: Partial<ClaudeCodeSettings> = {
59
+ env: DEFAULT_CLAUDE_CODE_ENV,
55
60
  attribution: {
56
61
  commit: '',
57
62
  pr: '',
@@ -77,32 +82,20 @@ export const generateHookCommands = async (targetId: string): Promise<HookConfig
77
82
  * Default hook commands (fallback)
78
83
  * Simplified to only include notification hook
79
84
  */
80
- export const DEFAULT_HOOKS: HookConfig = {
85
+ const DEFAULT_HOOKS: HookConfig = {
81
86
  notificationCommand: 'sylphx-flow hook --type notification --target claude-code',
82
87
  };
83
88
 
84
89
  /**
85
- * Parse JSON settings (pure)
86
- */
87
- export const parseSettings = (content: string): Result<ClaudeCodeSettings, ConfigError> => {
88
- return tryCatch(
89
- () => JSON.parse(content) as ClaudeCodeSettings,
90
- (error) =>
91
- configError('Failed to parse Claude Code settings', {
92
- cause: error instanceof Error ? error : undefined,
93
- })
94
- );
95
- };
96
-
97
- /**
98
- * Build hook configuration (pure)
90
+ * Process settings: parse existing or create new, merge hooks, serialize (pure)
99
91
  */
100
- export const buildHookConfiguration = (
101
- config: HookConfig = DEFAULT_HOOKS
102
- ): ClaudeCodeSettings['hooks'] => {
103
- const notificationCommand = config.notificationCommand || DEFAULT_HOOKS.notificationCommand!;
92
+ export const processSettings = (
93
+ existingContent: string | null,
94
+ hookConfig: HookConfig = DEFAULT_HOOKS
95
+ ): Result<string, ConfigError> => {
96
+ const notificationCommand = hookConfig.notificationCommand || DEFAULT_HOOKS.notificationCommand!;
104
97
 
105
- return {
98
+ const hookConfiguration: ClaudeCodeSettings['hooks'] = {
106
99
  Notification: [
107
100
  {
108
101
  matcher: '',
@@ -115,98 +108,49 @@ export const buildHookConfiguration = (
115
108
  },
116
109
  ],
117
110
  };
118
- };
119
111
 
120
- /**
121
- * Merge settings with defaults and new hooks (pure)
122
- * Preserves existing values while adding missing defaults
123
- */
124
- export const mergeSettings = (
125
- existingSettings: ClaudeCodeSettings,
126
- hookConfig: HookConfig = DEFAULT_HOOKS
127
- ): ClaudeCodeSettings => {
128
- const newHooks = buildHookConfiguration(hookConfig);
112
+ const createNewSettings = (): ClaudeCodeSettings => ({
113
+ ...DEFAULT_CLAUDE_CODE_SETTINGS,
114
+ hooks: hookConfiguration,
115
+ });
129
116
 
130
- return {
131
- // Apply defaults first, then existing settings override
117
+ const serialize = (settings: ClaudeCodeSettings): string => JSON.stringify(settings, null, 2);
118
+
119
+ if (existingContent === null || existingContent.trim() === '') {
120
+ return success(serialize(createNewSettings()));
121
+ }
122
+
123
+ // Parse existing settings
124
+ const parseResult = tryCatch(
125
+ () => JSON.parse(existingContent) as ClaudeCodeSettings,
126
+ (error) =>
127
+ configError('Failed to parse Claude Code settings', {
128
+ cause: error instanceof Error ? error : undefined,
129
+ })
130
+ );
131
+
132
+ if (parseResult._tag === 'Failure') {
133
+ return success(serialize(createNewSettings()));
134
+ }
135
+
136
+ // Merge with existing
137
+ const existingSettings = parseResult.value;
138
+ const merged: ClaudeCodeSettings = {
132
139
  ...DEFAULT_CLAUDE_CODE_SETTINGS,
133
140
  ...existingSettings,
134
- // Deep merge env variables
135
141
  env: {
136
142
  ...DEFAULT_CLAUDE_CODE_SETTINGS.env,
137
143
  ...(existingSettings.env || {}),
138
144
  },
139
- // Deep merge attribution
140
145
  attribution: {
141
146
  ...DEFAULT_CLAUDE_CODE_SETTINGS.attribution,
142
147
  ...(existingSettings.attribution || {}),
143
148
  },
144
- // Merge hooks
145
149
  hooks: {
146
150
  ...(existingSettings.hooks || {}),
147
- ...newHooks,
151
+ ...hookConfiguration,
148
152
  },
149
153
  };
150
- };
151
-
152
- /**
153
- * Create settings with defaults and hooks (pure)
154
- * Includes optimal Claude Code settings for extended output, thinking mode, and clean attribution
155
- */
156
- export const createSettings = (hookConfig: HookConfig = DEFAULT_HOOKS): ClaudeCodeSettings => {
157
- return {
158
- ...DEFAULT_CLAUDE_CODE_SETTINGS,
159
- hooks: buildHookConfiguration(hookConfig),
160
- };
161
- };
162
-
163
- /**
164
- * Serialize settings to JSON (pure)
165
- */
166
- export const serializeSettings = (settings: ClaudeCodeSettings): string => {
167
- return JSON.stringify(settings, null, 2);
168
- };
169
-
170
- /**
171
- * Get success message (pure)
172
- */
173
- export const getSuccessMessage = (): string => {
174
- return 'Claude Code hook configured: Notification';
175
- };
176
-
177
- /**
178
- * Process settings: parse existing or create new, merge hooks, serialize (pure)
179
- */
180
- export const processSettings = (
181
- existingContent: string | null,
182
- hookConfig: HookConfig = DEFAULT_HOOKS
183
- ): Result<string, ConfigError> => {
184
- if (existingContent === null || existingContent.trim() === '') {
185
- // No existing settings, create new
186
- const settings = createSettings(hookConfig);
187
- return success(serializeSettings(settings));
188
- }
189
-
190
- // Parse existing settings
191
- const parseResult = parseSettings(existingContent);
192
- if (parseResult._tag === 'Failure') {
193
- // If parsing fails, create new settings
194
- const settings = createSettings(hookConfig);
195
- return success(serializeSettings(settings));
196
- }
197
-
198
- // Merge with existing
199
- const merged = mergeSettings(parseResult.value, hookConfig);
200
- return success(serializeSettings(merged));
201
- };
202
-
203
- /**
204
- * Validate hook configuration (pure)
205
- */
206
- export const validateHookConfig = (config: HookConfig): Result<HookConfig, ConfigError> => {
207
- if (config.notificationCommand !== undefined && config.notificationCommand.trim() === '') {
208
- return failure(configError('Notification command cannot be empty'));
209
- }
210
154
 
211
- return success(config);
155
+ return success(serialize(merged));
212
156
  };