@sylphx/flow 3.18.0 → 3.19.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.
@@ -4,14 +4,18 @@
4
4
  * All projects store data in ~/.sylphx-flow/ isolated by project hash
5
5
  */
6
6
 
7
+ import { exec } from 'node:child_process';
7
8
  import crypto from 'node:crypto';
8
9
  import { existsSync } from 'node:fs';
9
10
  import fs from 'node:fs/promises';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
13
+ import { promisify } from 'node:util';
12
14
  import type { Target } from '../types/target.types.js';
13
15
  import { targetManager } from './target-manager.js';
14
16
 
17
+ const execAsync = promisify(exec);
18
+
15
19
  export interface ProjectPaths {
16
20
  sessionFile: string;
17
21
  backupsDir: string;
@@ -63,26 +67,20 @@ export class ProjectManager {
63
67
  * Initialize Flow directories
64
68
  */
65
69
  async initialize(): Promise<void> {
66
- const dirs = [
67
- this.flowHomeDir,
68
- path.join(this.flowHomeDir, 'sessions'),
69
- path.join(this.flowHomeDir, 'backups'),
70
- path.join(this.flowHomeDir, 'secrets'),
71
- path.join(this.flowHomeDir, 'templates'),
72
- ];
73
-
74
- for (const dir of dirs) {
75
- await fs.mkdir(dir, { recursive: true });
76
- }
70
+ await Promise.all([
71
+ fs.mkdir(path.join(this.flowHomeDir, 'sessions'), { recursive: true }),
72
+ fs.mkdir(path.join(this.flowHomeDir, 'backups'), { recursive: true }),
73
+ fs.mkdir(path.join(this.flowHomeDir, 'secrets'), { recursive: true }),
74
+ fs.mkdir(path.join(this.flowHomeDir, 'templates'), { recursive: true }),
75
+ ]);
77
76
  }
78
77
 
79
78
  /**
80
- * Check if a command is available on the system
79
+ * Check if a command is available on the system (non-blocking)
81
80
  */
82
81
  private async isCommandAvailable(command: string): Promise<boolean> {
83
82
  try {
84
- const { execSync } = await import('node:child_process');
85
- execSync(`which ${command}`, { stdio: 'ignore' });
83
+ await execAsync(`which ${command}`, { timeout: 5000 });
86
84
  return true;
87
85
  } catch {
88
86
  return false;
@@ -208,6 +208,34 @@ export class SessionManager {
208
208
  }
209
209
  }
210
210
 
211
+ /**
212
+ * Prune old session history files to prevent unbounded accumulation
213
+ * Keeps the most recent N history entries (sorted by session timestamp in filename)
214
+ */
215
+ async cleanupSessionHistory(keepLast: number = 50): Promise<void> {
216
+ const flowHome = this.projectManager.getFlowHomeDir();
217
+ const historyDir = path.join(flowHome, 'sessions', 'history');
218
+
219
+ if (!existsSync(historyDir)) {
220
+ return;
221
+ }
222
+
223
+ const files = await fs.readdir(historyDir);
224
+ const sessionFiles = files
225
+ .filter((f) => f.endsWith('.json'))
226
+ .sort() // session-{timestamp}.json sorts chronologically
227
+ .reverse(); // newest first
228
+
229
+ // Delete files beyond keepLast
230
+ for (const file of sessionFiles.slice(keepLast)) {
231
+ try {
232
+ await fs.unlink(path.join(historyDir, file));
233
+ } catch {
234
+ // Ignore errors — file might already be deleted
235
+ }
236
+ }
237
+ }
238
+
211
239
  /**
212
240
  * Recover from crashed session
213
241
  */
@@ -230,37 +258,4 @@ export class SessionManager {
230
258
  // File might not exist
231
259
  }
232
260
  }
233
-
234
- /**
235
- * Clean up old session history
236
- */
237
- async cleanupOldSessions(keepLast: number = 10): Promise<void> {
238
- const flowHome = this.projectManager.getFlowHomeDir();
239
- const historyDir = path.join(flowHome, 'sessions', 'history');
240
-
241
- if (!existsSync(historyDir)) {
242
- return;
243
- }
244
-
245
- const files = await fs.readdir(historyDir);
246
- const sessions = await Promise.all(
247
- files.map(async (file) => {
248
- const filePath = path.join(historyDir, file);
249
- const data = await fs.readFile(filePath, 'utf-8');
250
- const session = JSON.parse(data) as Session;
251
- return { file, session };
252
- })
253
- );
254
-
255
- // Sort by start time (newest first)
256
- sessions.sort(
257
- (a, b) => new Date(b.session.startTime).getTime() - new Date(a.session.startTime).getTime()
258
- );
259
-
260
- // Remove old sessions
261
- const toRemove = sessions.slice(keepLast);
262
- for (const { file } of toRemove) {
263
- await fs.unlink(path.join(historyDir, file));
264
- }
265
- }
266
261
  }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { ConfigService } from '../services/config-service.js';
5
5
  import type { Target } from '../types/target.types.js';
6
- import { targetManager } from './target-manager.js';
6
+ import { tryResolveTarget } from './target-resolver.js';
7
7
 
8
8
  const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = path.dirname(__filename);
@@ -43,17 +43,6 @@ export class StateDetector {
43
43
  this.projectPath = projectPath;
44
44
  }
45
45
 
46
- /**
47
- * Resolve target from ID string to Target object
48
- */
49
- private resolveTarget(targetId: string): Target | null {
50
- const targetOption = targetManager.getTarget(targetId);
51
- if (targetOption._tag === 'None') {
52
- return null;
53
- }
54
- return targetOption.value;
55
- }
56
-
57
46
  async detect(): Promise<ProjectState> {
58
47
  const state: ProjectState = {
59
48
  initialized: false,
@@ -95,7 +84,7 @@ export class StateDetector {
95
84
  }
96
85
 
97
86
  // Resolve target to get config
98
- const target = state.target ? this.resolveTarget(state.target) : null;
87
+ const target = state.target ? tryResolveTarget(state.target) : null;
99
88
 
100
89
  // Check components based on target config
101
90
  if (target) {
@@ -376,7 +365,7 @@ export class StateDetector {
376
365
  targetId: string
377
366
  ): Promise<{ version: string | null; latestVersion: string | null }> {
378
367
  try {
379
- const target = this.resolveTarget(targetId);
368
+ const target = tryResolveTarget(targetId);
380
369
  if (!target) {
381
370
  return { version: null, latestVersion: null };
382
371
  }
@@ -428,7 +417,7 @@ export class StateDetector {
428
417
 
429
418
  // Check required components based on target
430
419
  if (state.initialized && state.target) {
431
- const target = this.resolveTarget(state.target);
420
+ const target = tryResolveTarget(state.target);
432
421
  // CLI-based targets (category: 'cli') require agents to be installed
433
422
  if (target && target.category === 'cli' && !state.components.agents.installed) {
434
423
  return true; // CLI target initialized but no agents
@@ -1,27 +1,32 @@
1
1
  /**
2
- * Target Resolver
3
- * Shared utility for resolving target IDs to Target objects
4
- * Eliminates duplication across BackupManager and SecretsManager
2
+ * Target Resolver — Single Source of Truth for target ID → Target resolution
5
3
  */
6
4
 
7
5
  import type { Target } from '../types/target.types.js';
8
6
  import { targetManager } from './target-manager.js';
9
7
 
10
8
  /**
11
- * Resolve a target from ID string to Target object
9
+ * Resolve target ID to Target object, returns null if not found
10
+ */
11
+ export function tryResolveTarget(targetId: string): Target | null {
12
+ const targetOption = targetManager.getTarget(targetId);
13
+ return targetOption._tag === 'Some' ? targetOption.value : null;
14
+ }
15
+
16
+ /**
17
+ * Resolve target ID to Target object
12
18
  * @throws Error if target ID is not found
13
19
  */
14
20
  export function resolveTarget(targetId: string): Target {
15
- const targetOption = targetManager.getTarget(targetId);
16
- if (targetOption._tag === 'None') {
21
+ const target = tryResolveTarget(targetId);
22
+ if (!target) {
17
23
  throw new Error(`Unknown target: ${targetId}`);
18
24
  }
19
- return targetOption.value;
25
+ return target;
20
26
  }
21
27
 
22
28
  /**
23
- * Resolve target, accepting either string ID or Target object
24
- * Returns the Target object in both cases
29
+ * Resolve target from either string ID or Target object
25
30
  */
26
31
  export function resolveTargetOrId(targetOrId: Target | string): Target {
27
32
  return typeof targetOrId === 'string' ? resolveTarget(targetOrId) : targetOrId;
@@ -30,15 +30,13 @@ export class TemplateLoader {
30
30
  const commandsDir = path.join(this.assetsDir, 'slash-commands');
31
31
  const skillsDir = path.join(this.assetsDir, 'skills');
32
32
  const mcpConfigPath = path.join(this.assetsDir, 'mcp-servers.json');
33
- const outputStylesDir = path.join(this.assetsDir, 'output-styles');
34
33
 
35
34
  // Load all directories in parallel
36
- const [agents, commands, skills, mcpServers, singleFiles, rules] = await Promise.all([
35
+ const [agents, commands, skills, mcpServers, rules] = await Promise.all([
37
36
  existsSync(agentsDir) ? this.loadAgents(agentsDir) : [],
38
37
  existsSync(commandsDir) ? this.loadCommands(commandsDir) : [],
39
38
  existsSync(skillsDir) ? this.loadSkills(skillsDir) : [],
40
39
  existsSync(mcpConfigPath) ? this.loadMCPServers(mcpConfigPath) : [],
41
- existsSync(outputStylesDir) ? this.loadSingleFiles(outputStylesDir) : [],
42
40
  this.loadRules(),
43
41
  ]);
44
42
 
@@ -48,8 +46,7 @@ export class TemplateLoader {
48
46
  skills,
49
47
  rules,
50
48
  mcpServers,
51
- hooks: [],
52
- singleFiles,
49
+ singleFiles: [],
53
50
  };
54
51
  }
55
52
 
@@ -134,11 +131,13 @@ export class TemplateLoader {
134
131
  /**
135
132
  * Load MCP servers configuration
136
133
  */
137
- private async loadMCPServers(configPath: string): Promise<Array<{ name: string; config: any }>> {
134
+ private async loadMCPServers(
135
+ configPath: string
136
+ ): Promise<Array<{ name: string; config: Record<string, unknown> }>> {
138
137
  const data = await fs.readFile(configPath, 'utf-8');
139
- const config = JSON.parse(data);
138
+ const config = JSON.parse(data) as Record<string, Record<string, unknown>>;
140
139
 
141
- const servers = [];
140
+ const servers: Array<{ name: string; config: Record<string, unknown> }> = [];
142
141
  for (const [name, serverConfig] of Object.entries(config)) {
143
142
  servers.push({ name, config: serverConfig });
144
143
  }
@@ -146,31 +145,6 @@ export class TemplateLoader {
146
145
  return servers;
147
146
  }
148
147
 
149
- /**
150
- * Load single files (parallel loading)
151
- */
152
- private async loadSingleFiles(
153
- singleFilesDir: string
154
- ): Promise<Array<{ path: string; content: string }>> {
155
- const entries = await fs.readdir(singleFilesDir);
156
-
157
- const results = await Promise.all(
158
- entries.map(async (entry) => {
159
- const filePath = path.join(singleFilesDir, entry);
160
- const stat = await fs.stat(filePath);
161
-
162
- if (!stat.isFile()) {
163
- return null;
164
- }
165
-
166
- const content = await fs.readFile(filePath, 'utf-8');
167
- return { path: entry, content };
168
- })
169
- );
170
-
171
- return results.filter((r): r is { path: string; content: string } => r !== null);
172
- }
173
-
174
148
  /**
175
149
  * Get assets directory path
176
150
  */
@@ -9,7 +9,7 @@ import { CLIError } from '../utils/error-handler.js';
9
9
  import { detectPackageManager, getUpgradeCommand } from '../utils/package-manager-detector.js';
10
10
  import { createSpinner, log } from '../utils/prompts/index.js';
11
11
  import type { ProjectState } from './state-detector.js';
12
- import { targetManager } from './target-manager.js';
12
+ import { tryResolveTarget } from './target-resolver.js';
13
13
 
14
14
  const execAsync = promisify(exec);
15
15
 
@@ -152,23 +152,12 @@ export class UpgradeManager {
152
152
  }
153
153
  }
154
154
 
155
- /**
156
- * Resolve target from ID string to Target object
157
- */
158
- private resolveTarget(targetId: string): Target | null {
159
- const targetOption = targetManager.getTarget(targetId);
160
- if (targetOption._tag === 'None') {
161
- return null;
162
- }
163
- return targetOption.value;
164
- }
165
-
166
155
  async upgradeTarget(state: ProjectState, autoInstall: boolean = false): Promise<boolean> {
167
156
  if (!state.target || !state.targetLatestVersion) {
168
157
  return false;
169
158
  }
170
159
 
171
- const target = this.resolveTarget(state.target);
160
+ const target = tryResolveTarget(state.target);
172
161
  if (!target) {
173
162
  return false;
174
163
  }
@@ -263,7 +252,7 @@ export class UpgradeManager {
263
252
  const configPath = path.join(this.projectPath, getProjectSettingsFile());
264
253
  const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
265
254
  if (config.target) {
266
- return this.resolveTarget(config.target);
255
+ return tryResolveTarget(config.target);
267
256
  }
268
257
  } catch {
269
258
  // Cannot read config
@@ -355,7 +344,7 @@ export class UpgradeManager {
355
344
  }
356
345
 
357
346
  private async getCurrentTargetVersion(targetId: string): Promise<string | null> {
358
- const target = this.resolveTarget(targetId);
347
+ const target = tryResolveTarget(targetId);
359
348
  if (!target) {
360
349
  return null;
361
350
  }
package/src/index.ts CHANGED
@@ -98,48 +98,19 @@ export async function runCLI(): Promise<void> {
98
98
  * Set up global error handlers for uncaught exceptions and unhandled rejections
99
99
  */
100
100
  function setupGlobalErrorHandling(): void {
101
- // Handle uncaught exceptions
102
- process.on('uncaughtException', (error) => {
103
- console.error('✗ Uncaught Exception:');
104
- console.error(` ${error.message}`);
105
- if (process.env.NODE_ENV === 'development') {
106
- console.error(' Stack trace:', error.stack);
107
- }
108
- process.exit(1);
109
- });
110
-
111
- // Handle unhandled promise rejections
112
- process.on('unhandledRejection', (reason, promise) => {
113
- // Ignore AbortError - this is expected when user cancels operations
101
+ // Handle unhandled promise rejections (non-fatal, log only)
102
+ process.on('unhandledRejection', (reason) => {
103
+ // Ignore AbortError — expected when user cancels operations
114
104
  if (reason instanceof Error && reason.name === 'AbortError') {
115
105
  return;
116
106
  }
117
-
118
- // Only log unhandled rejections in development mode
119
- // Don't exit the process - let the application handle errors gracefully
120
107
  if (process.env.NODE_ENV === 'development' || process.env.DEBUG) {
121
- console.error('✗ Unhandled Promise Rejection:');
122
- console.error(` Reason: ${reason}`);
123
- console.error(' Promise:', promise);
108
+ console.error('✗ Unhandled Promise Rejection:', reason);
124
109
  }
125
110
  });
126
111
 
127
- // Handle process termination gracefully
128
- process.on('SIGINT', () => {
129
- console.log('\nSylphx Flow CLI terminated by user');
130
- process.exit(0);
131
- });
132
-
133
- process.on('SIGTERM', () => {
134
- console.log('\nSylphx Flow CLI terminated');
135
- process.exit(0);
136
- });
137
-
138
- // Ensure clean exit by allowing the event loop to drain
139
- process.on('beforeExit', () => {
140
- // Node.js will exit automatically after this handler completes
141
- // No explicit process.exit() needed
142
- });
112
+ // SIGINT/SIGTERM are handled by CleanupHandler which does async backup restoration.
113
+ // DO NOT register handlers here — process.exit() would preempt cleanup.
143
114
  }
144
115
 
145
116
  /**
@@ -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,
@@ -16,9 +13,9 @@ import {
16
13
  } from '../utils/config/target-utils.js';
17
14
  import { CLIError } from '../utils/error-handler.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';
@@ -70,6 +67,7 @@ export const claudeCodeTarget: Target = {
70
67
  createConfigFile: true,
71
68
  useSecretFiles: false,
72
69
  },
70
+ supportsMCP: true,
73
71
  },
74
72
 
75
73
  /**
@@ -271,7 +269,7 @@ Please begin your response with a comprehensive summary of all the instructions
271
269
  const child = spawn('claude', args, {
272
270
  stdio: 'inherit',
273
271
  shell: false,
274
- env: process.env, // Pass environment variables including ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY
272
+ env: { ...process.env, ...DEFAULT_CLAUDE_CODE_ENV },
275
273
  });
276
274
 
277
275
  child.on('spawn', () => {
@@ -298,17 +296,17 @@ Please begin your response with a comprehensive summary of all the instructions
298
296
  });
299
297
  });
300
298
  } 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
- }
305
- if (err.code) {
306
- throw new CLIError(`Claude Code exited with code ${err.code}`, 'CLAUDE_ERROR');
299
+ if (error instanceof Error) {
300
+ const errWithCode = error as Error & { code?: string | number };
301
+ if (errWithCode.code === 'ENOENT') {
302
+ throw new CLIError('Claude Code not found. Please install it first.', 'CLAUDE_NOT_FOUND');
303
+ }
304
+ if (errWithCode.code !== undefined) {
305
+ throw new CLIError(`Claude Code exited with code ${errWithCode.code}`, 'CLAUDE_ERROR');
306
+ }
307
+ throw new CLIError(`Failed to execute Claude Code: ${error.message}`, 'CLAUDE_ERROR');
307
308
  }
308
- throw new CLIError(
309
- `Failed to execute Claude Code: ${(error as Error).message}`,
310
- 'CLAUDE_ERROR'
311
- );
309
+ throw new CLIError(`Failed to execute Claude Code: ${String(error)}`, 'CLAUDE_ERROR');
312
310
  }
313
311
  },
314
312
 
@@ -370,10 +368,10 @@ Please begin your response with a comprehensive summary of all the instructions
370
368
  transformRulesContent: stripFrontMatter,
371
369
 
372
370
  /**
373
- * Setup hooks for Claude Code
374
- * Configure session and prompt hooks for system information display
371
+ * Apply Claude Code settings (attribution, hooks, env, thinking mode)
372
+ * Merges Flow defaults into .claude/settings.json, preserving user settings
375
373
  */
376
- async setupHooks(cwd: string, _options: CommonOptions): Promise<SetupResult> {
374
+ async applySettings(cwd: string, _options: CommonOptions): Promise<SetupResult> {
377
375
  const { processSettings, generateHookCommands } = await import(
378
376
  './functional/claude-code-logic.js'
379
377
  );
@@ -425,95 +423,6 @@ Please begin your response with a comprehensive summary of all the instructions
425
423
  message: 'Configured notification hook',
426
424
  };
427
425
  },
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
426
  };
518
427
 
519
428
  /**