@sylphx/flow 2.1.3 → 2.1.4

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 (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +44 -0
  3. package/package.json +79 -73
  4. package/src/commands/flow/execute-v2.ts +37 -29
  5. package/src/commands/flow/prompt.ts +5 -3
  6. package/src/commands/flow/types.ts +0 -2
  7. package/src/commands/flow-command.ts +20 -13
  8. package/src/commands/hook-command.ts +1 -3
  9. package/src/commands/settings-command.ts +36 -33
  10. package/src/config/ai-config.ts +60 -41
  11. package/src/core/agent-loader.ts +11 -6
  12. package/src/core/attach-manager.ts +92 -84
  13. package/src/core/backup-manager.ts +35 -29
  14. package/src/core/cleanup-handler.ts +11 -8
  15. package/src/core/error-handling.ts +23 -30
  16. package/src/core/flow-executor.ts +58 -76
  17. package/src/core/formatting/bytes.ts +2 -4
  18. package/src/core/functional/async.ts +5 -4
  19. package/src/core/functional/error-handler.ts +2 -2
  20. package/src/core/git-stash-manager.ts +21 -10
  21. package/src/core/installers/file-installer.ts +0 -1
  22. package/src/core/installers/mcp-installer.ts +0 -1
  23. package/src/core/project-manager.ts +24 -18
  24. package/src/core/secrets-manager.ts +54 -73
  25. package/src/core/session-manager.ts +20 -22
  26. package/src/core/state-detector.ts +139 -80
  27. package/src/core/template-loader.ts +13 -31
  28. package/src/core/upgrade-manager.ts +122 -69
  29. package/src/index.ts +8 -5
  30. package/src/services/auto-upgrade.ts +1 -1
  31. package/src/services/config-service.ts +41 -29
  32. package/src/services/global-config.ts +2 -2
  33. package/src/services/target-installer.ts +9 -7
  34. package/src/targets/claude-code.ts +24 -12
  35. package/src/targets/opencode.ts +17 -6
  36. package/src/types/cli.types.ts +2 -2
  37. package/src/types/provider.types.ts +1 -7
  38. package/src/types/session.types.ts +11 -11
  39. package/src/types/target.types.ts +3 -1
  40. package/src/types/todo.types.ts +1 -1
  41. package/src/types.ts +1 -1
  42. package/src/utils/__tests__/package-manager-detector.test.ts +6 -6
  43. package/src/utils/agent-enhancer.ts +4 -4
  44. package/src/utils/config/paths.ts +3 -1
  45. package/src/utils/config/target-utils.ts +2 -2
  46. package/src/utils/display/banner.ts +2 -2
  47. package/src/utils/display/notifications.ts +58 -45
  48. package/src/utils/display/status.ts +29 -12
  49. package/src/utils/files/file-operations.ts +1 -1
  50. package/src/utils/files/sync-utils.ts +38 -41
  51. package/src/utils/index.ts +19 -27
  52. package/src/utils/package-manager-detector.ts +15 -5
  53. package/src/utils/security/security.ts +8 -4
  54. package/src/utils/target-selection.ts +5 -2
  55. package/src/utils/version.ts +4 -2
  56. package/src/commands/flow-orchestrator.ts +0 -328
  57. package/src/commands/init-command.ts +0 -92
  58. package/src/commands/init-core.ts +0 -331
  59. package/src/core/agent-manager.ts +0 -174
  60. package/src/core/loop-controller.ts +0 -200
  61. package/src/core/rule-loader.ts +0 -147
  62. package/src/core/rule-manager.ts +0 -240
  63. package/src/services/claude-config-service.ts +0 -252
  64. package/src/services/first-run-setup.ts +0 -220
  65. package/src/services/smart-config-service.ts +0 -269
  66. package/src/types/api.types.ts +0 -9
@@ -5,10 +5,9 @@
5
5
  */
6
6
 
7
7
  import { exec } from 'node:child_process';
8
- import { promisify } from 'node:util';
9
- import fs from 'node:fs/promises';
10
- import path from 'node:path';
11
8
  import { existsSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { promisify } from 'node:util';
12
11
  import chalk from 'chalk';
13
12
 
14
13
  const execAsync = promisify(exec);
@@ -39,7 +38,10 @@ export class GitStashManager {
39
38
  if (existsSync(claudeDir)) {
40
39
  try {
41
40
  const { stdout } = await execAsync('git ls-files .claude', { cwd: projectPath });
42
- const claudeFiles = stdout.trim().split('\n').filter(f => f);
41
+ const claudeFiles = stdout
42
+ .trim()
43
+ .split('\n')
44
+ .filter((f) => f);
43
45
  files.push(...claudeFiles);
44
46
  } catch {
45
47
  // Directory not tracked in git
@@ -51,7 +53,10 @@ export class GitStashManager {
51
53
  if (existsSync(opencodeDir)) {
52
54
  try {
53
55
  const { stdout } = await execAsync('git ls-files .opencode', { cwd: projectPath });
54
- const opencodeFiles = stdout.trim().split('\n').filter(f => f);
56
+ const opencodeFiles = stdout
57
+ .trim()
58
+ .split('\n')
59
+ .filter((f) => f);
55
60
  files.push(...opencodeFiles);
56
61
  } catch {
57
62
  // Directory not tracked in git
@@ -90,9 +95,11 @@ export class GitStashManager {
90
95
  }
91
96
 
92
97
  if (this.skipWorktreeFiles.length > 0) {
93
- console.log(chalk.dim(` ✓ Hiding ${this.skipWorktreeFiles.length} settings file(s) from git\n`));
98
+ console.log(
99
+ chalk.dim(` ✓ Hiding ${this.skipWorktreeFiles.length} settings file(s) from git\n`)
100
+ );
94
101
  }
95
- } catch (error) {
102
+ } catch (_error) {
96
103
  console.log(chalk.yellow(' ⚠️ Could not hide settings from git\n'));
97
104
  }
98
105
  }
@@ -116,11 +123,15 @@ export class GitStashManager {
116
123
  }
117
124
  }
118
125
 
119
- console.log(chalk.dim(` ✓ Restored git tracking for ${this.skipWorktreeFiles.length} file(s)\n`));
126
+ console.log(
127
+ chalk.dim(` ✓ Restored git tracking for ${this.skipWorktreeFiles.length} file(s)\n`)
128
+ );
120
129
  this.skipWorktreeFiles = [];
121
- } catch (error: any) {
130
+ } catch {
122
131
  console.log(chalk.yellow(' ⚠️ Could not restore git tracking'));
123
- console.log(chalk.yellow(' Run manually: git update-index --no-skip-worktree .claude/* .opencode/*\n'));
132
+ console.log(
133
+ chalk.yellow(' Run manually: git update-index --no-skip-worktree .claude/* .opencode/*\n')
134
+ );
124
135
  }
125
136
  }
126
137
 
@@ -256,4 +256,3 @@ export async function installFile(
256
256
  console.log(`${action} file: ${targetFile.replace(`${process.cwd()}/`, '')}`);
257
257
  }
258
258
  }
259
-
@@ -177,4 +177,3 @@ export function createMCPInstaller(target: Target): MCPInstaller {
177
177
  setupMCP,
178
178
  };
179
179
  }
180
-
@@ -5,10 +5,12 @@
5
5
  */
6
6
 
7
7
  import crypto from 'node:crypto';
8
- import path from 'node:path';
9
- import os from 'node:os';
10
8
  import { existsSync } from 'node:fs';
11
9
  import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import type { Target } from '../types/target.types.js';
13
+ import { targetManager } from './target-manager.js';
12
14
 
13
15
  export interface ProjectPaths {
14
16
  sessionFile: string;
@@ -31,11 +33,7 @@ export class ProjectManager {
31
33
  */
32
34
  getProjectHash(projectPath: string): string {
33
35
  const absolutePath = path.resolve(projectPath);
34
- return crypto
35
- .createHash('sha256')
36
- .update(absolutePath)
37
- .digest('hex')
38
- .substring(0, 16);
36
+ return crypto.createHash('sha256').update(absolutePath).digest('hex').substring(0, 16);
39
37
  }
40
38
 
41
39
  /**
@@ -128,12 +126,9 @@ export class ProjectManager {
128
126
  /**
129
127
  * Save project-specific target preference to global config
130
128
  */
131
- async saveProjectTargetPreference(
132
- projectHash: string,
133
- target: 'claude-code' | 'opencode'
134
- ): Promise<void> {
129
+ async saveProjectTargetPreference(projectHash: string, target: string): Promise<void> {
135
130
  const prefsPath = path.join(this.flowHomeDir, 'project-preferences.json');
136
- let prefs: any = { projects: {} };
131
+ let prefs: { projects: Record<string, { target?: string }> } = { projects: {} };
137
132
 
138
133
  if (existsSync(prefsPath)) {
139
134
  try {
@@ -156,11 +151,11 @@ export class ProjectManager {
156
151
  }
157
152
 
158
153
  /**
159
- * Detect target platform (claude-code or opencode)
154
+ * Detect target platform
160
155
  * New strategy: Detect based on installed commands, not folders
161
156
  * Priority: saved preference > installed commands > global default
162
157
  */
163
- async detectTarget(projectPath: string): Promise<'claude-code' | 'opencode'> {
158
+ async detectTarget(projectPath: string): Promise<string> {
164
159
  const projectHash = this.getProjectHash(projectPath);
165
160
 
166
161
  // 1. Check if we already have a saved preference for this project
@@ -217,11 +212,22 @@ export class ProjectManager {
217
212
 
218
213
  /**
219
214
  * Get target config directory for project
215
+ * @param projectPath - The project root path
216
+ * @param target - Either a Target object or target ID string
220
217
  */
221
- getTargetConfigDir(projectPath: string, target: 'claude-code' | 'opencode'): string {
222
- return target === 'claude-code'
223
- ? path.join(projectPath, '.claude')
224
- : path.join(projectPath, '.opencode');
218
+ getTargetConfigDir(projectPath: string, target: Target | string): string {
219
+ // If target is a string, look up the Target object
220
+ const targetObj =
221
+ typeof target === 'string'
222
+ ? targetManager.getTarget(target)
223
+ : { _tag: 'Some' as const, value: target };
224
+
225
+ if (targetObj._tag === 'None') {
226
+ // Fallback for unknown targets - use the ID as directory name
227
+ return path.join(projectPath, `.${target}`);
228
+ }
229
+
230
+ return path.join(projectPath, targetObj.value.config.configDir);
225
231
  }
226
232
 
227
233
  /**
@@ -4,12 +4,12 @@
4
4
  * Stores secrets in ~/.sylphx-flow/secrets/{project-hash}/
5
5
  */
6
6
 
7
+ import { existsSync } from 'node:fs';
7
8
  import fs from 'node:fs/promises';
8
9
  import path from 'node:path';
9
- import { existsSync } from 'node:fs';
10
- import crypto from 'node:crypto';
11
- import os from 'node:os';
12
- import { ProjectManager } from './project-manager.js';
10
+ import type { Target } from '../types/target.types.js';
11
+ import type { ProjectManager } from './project-manager.js';
12
+ import { targetManager } from './target-manager.js';
13
13
 
14
14
  export interface MCPSecrets {
15
15
  version: string;
@@ -30,19 +30,29 @@ export class SecretsManager {
30
30
  this.projectManager = projectManager;
31
31
  }
32
32
 
33
+ /**
34
+ * Resolve target from ID string to Target object
35
+ */
36
+ private resolveTarget(targetId: string): Target {
37
+ const targetOption = targetManager.getTarget(targetId);
38
+ if (targetOption._tag === 'None') {
39
+ throw new Error(`Unknown target: ${targetId}`);
40
+ }
41
+ return targetOption.value;
42
+ }
43
+
33
44
  /**
34
45
  * Extract MCP secrets from project config
35
46
  */
36
47
  async extractMCPSecrets(
37
48
  projectPath: string,
38
- projectHash: string,
39
- target: 'claude-code' | 'opencode'
49
+ _projectHash: string,
50
+ targetOrId: Target | string
40
51
  ): Promise<MCPSecrets> {
41
- const targetDir = this.projectManager.getTargetConfigDir(projectPath, target);
42
- const configPath =
43
- target === 'claude-code'
44
- ? path.join(targetDir, 'settings.json')
45
- : path.join(targetDir, '.mcp.json');
52
+ const target = typeof targetOrId === 'string' ? this.resolveTarget(targetOrId) : targetOrId;
53
+ // configFile is at project root, not in targetDir
54
+ const configPath = path.join(projectPath, target.config.configFile);
55
+ const mcpPath = target.config.mcpConfigPath;
46
56
 
47
57
  const secrets: MCPSecrets = {
48
58
  version: '1.0.0',
@@ -57,28 +67,32 @@ export class SecretsManager {
57
67
  try {
58
68
  const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
59
69
 
60
- // Extract MCP server secrets
61
- if (config.mcp && config.mcp.servers) {
62
- for (const [serverName, serverConfig] of Object.entries(config.mcp.servers)) {
70
+ // Extract MCP server secrets using target's mcpConfigPath
71
+ const mcpServers = config[mcpPath] as Record<string, unknown> | undefined;
72
+ if (mcpServers) {
73
+ for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
63
74
  const server = serverConfig as any;
64
75
 
65
- // Extract env vars (sensitive)
66
- if (server.env && Object.keys(server.env).length > 0) {
76
+ // Extract env vars (sensitive) - handle both 'env' and 'environment' keys
77
+ const envVars = server.env || server.environment;
78
+ if (envVars && Object.keys(envVars).length > 0) {
67
79
  secrets.servers[serverName] = {
68
- env: server.env,
80
+ env: envVars,
69
81
  };
70
82
  }
71
83
 
72
- // Extract args (may contain secrets)
73
- if (server.args && Array.isArray(server.args)) {
84
+ // Extract args (may contain secrets) - handle both 'args' and 'command' array
85
+ const args =
86
+ server.args || (Array.isArray(server.command) ? server.command.slice(1) : undefined);
87
+ if (args && Array.isArray(args) && args.length > 0) {
74
88
  if (!secrets.servers[serverName]) {
75
89
  secrets.servers[serverName] = {};
76
90
  }
77
- secrets.servers[serverName].args = server.args;
91
+ secrets.servers[serverName].args = args;
78
92
  }
79
93
  }
80
94
  }
81
- } catch (error) {
95
+ } catch (_error) {
82
96
  // Config file exists but cannot be parsed, skip
83
97
  }
84
98
 
@@ -123,19 +137,18 @@ export class SecretsManager {
123
137
  */
124
138
  async restoreSecrets(
125
139
  projectPath: string,
126
- projectHash: string,
127
- target: 'claude-code' | 'opencode',
140
+ _projectHash: string,
141
+ targetOrId: Target | string,
128
142
  secrets: MCPSecrets
129
143
  ): Promise<void> {
130
144
  if (Object.keys(secrets.servers).length === 0) {
131
145
  return;
132
146
  }
133
147
 
134
- const targetDir = this.projectManager.getTargetConfigDir(projectPath, target);
135
- const configPath =
136
- target === 'claude-code'
137
- ? path.join(targetDir, 'settings.json')
138
- : path.join(targetDir, '.mcp.json');
148
+ const target = typeof targetOrId === 'string' ? this.resolveTarget(targetOrId) : targetOrId;
149
+ // configFile is at project root, not in targetDir
150
+ const configPath = path.join(projectPath, target.config.configFile);
151
+ const mcpPath = target.config.mcpConfigPath;
139
152
 
140
153
  if (!existsSync(configPath)) {
141
154
  return;
@@ -144,18 +157,24 @@ export class SecretsManager {
144
157
  try {
145
158
  const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
146
159
 
147
- // Restore secrets to MCP servers
148
- if (config.mcp && config.mcp.servers) {
160
+ // Restore secrets to MCP servers using target's mcpConfigPath
161
+ const mcpServers = config[mcpPath] as Record<string, unknown> | undefined;
162
+ if (mcpServers) {
149
163
  for (const [serverName, serverSecrets] of Object.entries(secrets.servers)) {
150
- if (config.mcp.servers[serverName]) {
151
- // Restore env vars
164
+ const serverConfig = mcpServers[serverName] as Record<string, unknown> | undefined;
165
+ if (serverConfig) {
166
+ // Restore env vars - use the key that exists in config
152
167
  if (serverSecrets.env) {
153
- config.mcp.servers[serverName].env = serverSecrets.env;
168
+ if ('environment' in serverConfig) {
169
+ serverConfig.environment = serverSecrets.env;
170
+ } else {
171
+ serverConfig.env = serverSecrets.env;
172
+ }
154
173
  }
155
174
 
156
175
  // Restore args
157
176
  if (serverSecrets.args) {
158
- config.mcp.servers[serverName].args = serverSecrets.args;
177
+ serverConfig.args = serverSecrets.args;
159
178
  }
160
179
  }
161
180
  }
@@ -163,49 +182,11 @@ export class SecretsManager {
163
182
 
164
183
  // Write updated config
165
184
  await fs.writeFile(configPath, JSON.stringify(config, null, 2));
166
- } catch (error) {
185
+ } catch (_error) {
167
186
  // Config restore failed, skip
168
187
  }
169
188
  }
170
189
 
171
- /**
172
- * Encrypt secrets (optional - for enhanced security)
173
- */
174
- private async encrypt(data: string): Promise<string> {
175
- // Use machine ID + user HOME as stable key source
176
- const keySource = `${os.homedir()}-${os.hostname()}`;
177
- const key = crypto.scryptSync(keySource, 'sylphx-flow-salt', 32);
178
- const iv = crypto.randomBytes(16);
179
-
180
- const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
181
- let encrypted = cipher.update(data, 'utf8', 'hex');
182
- encrypted += cipher.final('hex');
183
-
184
- const authTag = cipher.getAuthTag();
185
-
186
- return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
187
- }
188
-
189
- /**
190
- * Decrypt secrets (optional)
191
- */
192
- private async decrypt(encrypted: string): Promise<string> {
193
- const [ivHex, authTagHex, encryptedData] = encrypted.split(':');
194
-
195
- const keySource = `${os.homedir()}-${os.hostname()}`;
196
- const key = crypto.scryptSync(keySource, 'sylphx-flow-salt', 32);
197
- const iv = Buffer.from(ivHex, 'hex');
198
- const authTag = Buffer.from(authTagHex, 'hex');
199
-
200
- const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
201
- decipher.setAuthTag(authTag);
202
-
203
- let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
204
- decrypted += decipher.final('utf8');
205
-
206
- return decrypted;
207
- }
208
-
209
190
  /**
210
191
  * Clear secrets for a project
211
192
  */
@@ -4,10 +4,11 @@
4
4
  * All sessions stored in ~/.sylphx-flow/sessions/
5
5
  */
6
6
 
7
+ import { existsSync } from 'node:fs';
7
8
  import fs from 'node:fs/promises';
8
9
  import path from 'node:path';
9
- import { existsSync } from 'node:fs';
10
- import { ProjectManager } from './project-manager.js';
10
+ import type { Target } from '../types/target.types.js';
11
+ import type { ProjectManager } from './project-manager.js';
11
12
 
12
13
  export interface Session {
13
14
  projectHash: string;
@@ -17,13 +18,13 @@ export interface Session {
17
18
  startTime: string;
18
19
  backupPath: string;
19
20
  status: 'active' | 'completed' | 'crashed';
20
- target: 'claude-code' | 'opencode';
21
+ target: string;
21
22
  cleanupRequired: boolean;
22
23
  // Multi-session support
23
- isOriginal: boolean; // First session that created backup
24
- sharedBackupId: string; // Shared backup ID for all sessions
25
- refCount: number; // Number of active sessions
26
- activePids: number[]; // All active PIDs sharing this session
24
+ isOriginal: boolean; // First session that created backup
25
+ sharedBackupId: string; // Shared backup ID for all sessions
26
+ refCount: number; // Number of active sessions
27
+ activePids: number[]; // All active PIDs sharing this session
27
28
  }
28
29
 
29
30
  export class SessionManager {
@@ -39,10 +40,12 @@ export class SessionManager {
39
40
  async startSession(
40
41
  projectPath: string,
41
42
  projectHash: string,
42
- target: 'claude-code' | 'opencode',
43
+ targetOrId: Target | string,
43
44
  backupPath: string,
44
45
  sessionId?: string
45
46
  ): Promise<{ session: Session; isFirstSession: boolean }> {
47
+ // Get target ID for storage
48
+ const targetId = typeof targetOrId === 'string' ? targetOrId : targetOrId.id;
46
49
  const paths = this.projectManager.getProjectPaths(projectHash);
47
50
 
48
51
  // Ensure sessions directory exists
@@ -74,7 +77,7 @@ export class SessionManager {
74
77
  startTime: new Date().toISOString(),
75
78
  backupPath,
76
79
  status: 'active',
77
- target,
80
+ target: targetId,
78
81
  cleanupRequired: true,
79
82
  isOriginal: true,
80
83
  sharedBackupId: newSessionId,
@@ -93,7 +96,9 @@ export class SessionManager {
93
96
  /**
94
97
  * Mark session as completed (with reference counting)
95
98
  */
96
- async endSession(projectHash: string): Promise<{ shouldRestore: boolean; session: Session | null }> {
99
+ async endSession(
100
+ projectHash: string
101
+ ): Promise<{ shouldRestore: boolean; session: Session | null }> {
97
102
  try {
98
103
  const session = await this.getActiveSession(projectHash);
99
104
 
@@ -104,7 +109,7 @@ export class SessionManager {
104
109
  const paths = this.projectManager.getProjectPaths(projectHash);
105
110
 
106
111
  // Remove current PID from active PIDs
107
- session.activePids = session.activePids.filter(pid => pid !== process.pid);
112
+ session.activePids = session.activePids.filter((pid) => pid !== process.pid);
108
113
  session.refCount = session.activePids.length;
109
114
 
110
115
  if (session.refCount === 0) {
@@ -115,12 +120,7 @@ export class SessionManager {
115
120
  const flowHome = this.projectManager.getFlowHomeDir();
116
121
 
117
122
  // Archive to history
118
- const historyPath = path.join(
119
- flowHome,
120
- 'sessions',
121
- 'history',
122
- `${session.sessionId}.json`
123
- );
123
+ const historyPath = path.join(flowHome, 'sessions', 'history', `${session.sessionId}.json`);
124
124
  await fs.mkdir(path.dirname(historyPath), { recursive: true });
125
125
  await fs.writeFile(historyPath, JSON.stringify(session, null, 2));
126
126
 
@@ -134,7 +134,7 @@ export class SessionManager {
134
134
 
135
135
  return { shouldRestore: false, session };
136
136
  }
137
- } catch (error) {
137
+ } catch (_error) {
138
138
  // Session file might not exist
139
139
  return { shouldRestore: false, session: null };
140
140
  }
@@ -203,7 +203,7 @@ export class SessionManager {
203
203
  // Send signal 0 to check if process exists
204
204
  process.kill(pid, 0);
205
205
  return true;
206
- } catch (error) {
206
+ } catch (_error) {
207
207
  return false;
208
208
  }
209
209
  }
@@ -254,9 +254,7 @@ export class SessionManager {
254
254
 
255
255
  // Sort by start time (newest first)
256
256
  sessions.sort(
257
- (a, b) =>
258
- new Date(b.session.startTime).getTime() -
259
- new Date(a.session.startTime).getTime()
257
+ (a, b) => new Date(b.session.startTime).getTime() - new Date(a.session.startTime).getTime()
260
258
  );
261
259
 
262
260
  // Remove old sessions