@sylphx/flow 2.1.2 → 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 (70) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +44 -0
  3. package/package.json +79 -73
  4. package/src/commands/flow/execute-v2.ts +39 -30
  5. package/src/commands/flow/index.ts +2 -4
  6. package/src/commands/flow/prompt.ts +5 -3
  7. package/src/commands/flow/types.ts +0 -9
  8. package/src/commands/flow-command.ts +20 -13
  9. package/src/commands/hook-command.ts +1 -3
  10. package/src/commands/settings-command.ts +36 -33
  11. package/src/config/ai-config.ts +60 -41
  12. package/src/core/agent-loader.ts +11 -6
  13. package/src/core/attach-manager.ts +92 -84
  14. package/src/core/backup-manager.ts +35 -29
  15. package/src/core/cleanup-handler.ts +11 -8
  16. package/src/core/error-handling.ts +23 -30
  17. package/src/core/flow-executor.ts +58 -76
  18. package/src/core/formatting/bytes.ts +2 -4
  19. package/src/core/functional/async.ts +5 -4
  20. package/src/core/functional/error-handler.ts +2 -2
  21. package/src/core/git-stash-manager.ts +21 -10
  22. package/src/core/installers/file-installer.ts +0 -1
  23. package/src/core/installers/mcp-installer.ts +0 -1
  24. package/src/core/project-manager.ts +24 -18
  25. package/src/core/secrets-manager.ts +54 -73
  26. package/src/core/session-manager.ts +20 -22
  27. package/src/core/state-detector.ts +139 -80
  28. package/src/core/template-loader.ts +13 -31
  29. package/src/core/upgrade-manager.ts +122 -69
  30. package/src/index.ts +8 -5
  31. package/src/services/auto-upgrade.ts +1 -1
  32. package/src/services/config-service.ts +41 -29
  33. package/src/services/global-config.ts +2 -2
  34. package/src/services/target-installer.ts +9 -7
  35. package/src/targets/claude-code.ts +28 -15
  36. package/src/targets/opencode.ts +17 -6
  37. package/src/types/cli.types.ts +2 -2
  38. package/src/types/provider.types.ts +1 -7
  39. package/src/types/session.types.ts +11 -11
  40. package/src/types/target.types.ts +3 -1
  41. package/src/types/todo.types.ts +1 -1
  42. package/src/types.ts +1 -1
  43. package/src/utils/__tests__/package-manager-detector.test.ts +6 -6
  44. package/src/utils/agent-enhancer.ts +111 -3
  45. package/src/utils/config/paths.ts +3 -1
  46. package/src/utils/config/target-utils.ts +2 -2
  47. package/src/utils/display/banner.ts +2 -2
  48. package/src/utils/display/notifications.ts +58 -45
  49. package/src/utils/display/status.ts +29 -12
  50. package/src/utils/files/file-operations.ts +1 -1
  51. package/src/utils/files/sync-utils.ts +38 -41
  52. package/src/utils/index.ts +19 -27
  53. package/src/utils/package-manager-detector.ts +15 -5
  54. package/src/utils/security/security.ts +8 -4
  55. package/src/utils/target-selection.ts +5 -2
  56. package/src/utils/version.ts +4 -2
  57. package/src/commands/flow/execute.ts +0 -453
  58. package/src/commands/flow/setup.ts +0 -312
  59. package/src/commands/flow-orchestrator.ts +0 -328
  60. package/src/commands/init-command.ts +0 -92
  61. package/src/commands/init-core.ts +0 -331
  62. package/src/commands/run-command.ts +0 -126
  63. package/src/core/agent-manager.ts +0 -174
  64. package/src/core/loop-controller.ts +0 -200
  65. package/src/core/rule-loader.ts +0 -147
  66. package/src/core/rule-manager.ts +0 -240
  67. package/src/services/claude-config-service.ts +0 -252
  68. package/src/services/first-run-setup.ts +0 -220
  69. package/src/services/smart-config-service.ts +0 -269
  70. package/src/types/api.types.ts +0 -9
@@ -14,12 +14,7 @@ export class BaseError extends Error {
14
14
  public readonly statusCode: number;
15
15
  public readonly details?: Record<string, unknown>;
16
16
 
17
- constructor(
18
- message: string,
19
- code: string,
20
- statusCode = 500,
21
- details?: Record<string, unknown>
22
- ) {
17
+ constructor(message: string, code: string, statusCode = 500, details?: Record<string, unknown>) {
23
18
  super(message);
24
19
  this.name = this.constructor.name;
25
20
  this.code = code;
@@ -176,7 +171,7 @@ export interface ErrorHandler {
176
171
  export class LoggerErrorHandler implements ErrorHandler {
177
172
  constructor(private level: 'error' | 'warn' | 'info' | 'debug' = 'error') {}
178
173
 
179
- canHandle(error: Error): boolean {
174
+ canHandle(_error: Error): boolean {
180
175
  return true; // Logger can handle all errors
181
176
  }
182
177
 
@@ -211,7 +206,7 @@ export class LoggerErrorHandler implements ErrorHandler {
211
206
  * Console error handler
212
207
  */
213
208
  export class ConsoleErrorHandler implements ErrorHandler {
214
- canHandle(error: Error): boolean {
209
+ canHandle(_error: Error): boolean {
215
210
  return true; // Console can handle all errors
216
211
  }
217
212
 
@@ -269,9 +264,7 @@ export class ErrorHandlerChain {
269
264
  /**
270
265
  * Global error handler
271
266
  */
272
- export const globalErrorHandler = new ErrorHandlerChain([
273
- new LoggerErrorHandler('error'),
274
- ]);
267
+ export const globalErrorHandler = new ErrorHandlerChain([new LoggerErrorHandler('error')]);
275
268
 
276
269
  /**
277
270
  * Set up global error handlers
@@ -371,7 +364,7 @@ export async function withRetry<T>(
371
364
  onRetry,
372
365
  } = options;
373
366
 
374
- let lastError: Error;
367
+ let lastError: Error = new Error('Unknown error');
375
368
 
376
369
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
377
370
  try {
@@ -381,7 +374,8 @@ export async function withRetry<T>(
381
374
  lastError = error instanceof Error ? error : new Error(String(error));
382
375
 
383
376
  // Check if error is retryable
384
- const isRetryable = retryableErrors.length === 0 ||
377
+ const isRetryable =
378
+ retryableErrors.length === 0 ||
385
379
  retryableErrors.includes((lastError as BaseError).code) ||
386
380
  retryableErrors.includes(lastError.constructor.name);
387
381
 
@@ -396,23 +390,21 @@ export async function withRetry<T>(
396
390
  }
397
391
 
398
392
  // Calculate delay
399
- const retryDelay = backoff === 'exponential'
400
- ? delay * Math.pow(2, attempt - 1)
401
- : delay * attempt;
393
+ const retryDelay = backoff === 'exponential' ? delay * 2 ** (attempt - 1) : delay * attempt;
402
394
 
403
395
  // Wait before retry
404
- await new Promise(resolve => setTimeout(resolve, retryDelay));
396
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
405
397
  }
406
398
  }
407
399
 
408
- await globalErrorHandler.handle(lastError!);
409
- return { success: false, error: lastError! };
400
+ await globalErrorHandler.handle(lastError);
401
+ return { success: false, error: lastError };
410
402
  }
411
403
 
412
404
  /**
413
405
  * Timeout wrapper
414
406
  */
415
- export async function withTimeout<T>(
407
+ export function withTimeout<T>(
416
408
  fn: () => Promise<T>,
417
409
  timeoutMs: number,
418
410
  timeoutMessage = 'Operation timed out'
@@ -447,26 +439,27 @@ export class CircuitBreaker {
447
439
  private failures = 0;
448
440
  private lastFailureTime = 0;
449
441
  private state: 'closed' | 'open' | 'half-open' = 'closed';
442
+ private readonly config: {
443
+ failureThreshold: number;
444
+ recoveryTimeMs: number;
445
+ monitoringPeriodMs: number;
446
+ };
450
447
 
451
448
  constructor(
452
- private options: {
449
+ options: {
453
450
  failureThreshold?: number;
454
451
  recoveryTimeMs?: number;
455
452
  monitoringPeriodMs?: number;
456
453
  } = {}
457
454
  ) {
458
- const {
459
- failureThreshold = 5,
460
- recoveryTimeMs = 60000,
461
- monitoringPeriodMs = 10000,
462
- } = options;
455
+ const { failureThreshold = 5, recoveryTimeMs = 60000, monitoringPeriodMs = 10000 } = options;
463
456
 
464
- this.options = { failureThreshold, recoveryTimeMs, monitoringPeriodMs };
457
+ this.config = { failureThreshold, recoveryTimeMs, monitoringPeriodMs };
465
458
  }
466
459
 
467
460
  async execute<T>(fn: () => Promise<T>): Promise<Result<T>> {
468
461
  if (this.state === 'open') {
469
- if (Date.now() - this.lastFailureTime > this.options.recoveryTimeMs!) {
462
+ if (Date.now() - this.lastFailureTime > this.config.recoveryTimeMs) {
470
463
  this.state = 'half-open';
471
464
  } else {
472
465
  return {
@@ -498,7 +491,7 @@ export class CircuitBreaker {
498
491
  this.failures++;
499
492
  this.lastFailureTime = Date.now();
500
493
 
501
- if (this.failures >= this.options.failureThreshold!) {
494
+ if (this.failures >= this.config.failureThreshold) {
502
495
  this.state = 'open';
503
496
  }
504
497
  }
@@ -516,4 +509,4 @@ export class CircuitBreaker {
516
509
  this.state = 'closed';
517
510
  this.lastFailureTime = 0;
518
511
  }
519
- }
512
+ }
@@ -5,14 +5,16 @@
5
5
  */
6
6
 
7
7
  import chalk from 'chalk';
8
- import { ProjectManager } from './project-manager.js';
9
- import { SessionManager } from './session-manager.js';
8
+ import type { Target } from '../types/target.types.js';
9
+ import { AttachManager, type AttachResult } from './attach-manager.js';
10
10
  import { BackupManager } from './backup-manager.js';
11
- import { AttachManager } from './attach-manager.js';
12
- import { SecretsManager } from './secrets-manager.js';
13
11
  import { CleanupHandler } from './cleanup-handler.js';
14
- import { TemplateLoader } from './template-loader.js';
15
12
  import { GitStashManager } from './git-stash-manager.js';
13
+ import { ProjectManager } from './project-manager.js';
14
+ import { SecretsManager } from './secrets-manager.js';
15
+ import { SessionManager } from './session-manager.js';
16
+ import { targetManager } from './target-manager.js';
17
+ import { TemplateLoader } from './template-loader.js';
16
18
 
17
19
  export interface FlowExecutorOptions {
18
20
  verbose?: boolean;
@@ -49,10 +51,7 @@ export class FlowExecutor {
49
51
  /**
50
52
  * Execute complete flow with attach mode (with multi-session support)
51
53
  */
52
- async execute(
53
- projectPath: string,
54
- options: FlowExecutorOptions = {}
55
- ): Promise<void> {
54
+ async execute(projectPath: string, options: FlowExecutorOptions = {}): Promise<void> {
56
55
  // Initialize Flow directories
57
56
  await this.projectManager.initialize();
58
57
 
@@ -76,7 +75,7 @@ export class FlowExecutor {
76
75
  // Joining existing session
77
76
  console.log(chalk.cyan('🔗 Joining existing session...'));
78
77
 
79
- const { session, isFirstSession } = await this.sessionManager.startSession(
78
+ const { session } = await this.sessionManager.startSession(
80
79
  projectPath,
81
80
  projectHash,
82
81
  target,
@@ -97,31 +96,21 @@ export class FlowExecutor {
97
96
  await this.gitStashManager.stashSettingsChanges(projectPath);
98
97
 
99
98
  console.log(chalk.cyan('💾 Creating backup...'));
100
- const backup = await this.backupManager.createBackup(
101
- projectPath,
102
- projectHash,
103
- target
104
- );
99
+ const backup = await this.backupManager.createBackup(projectPath, projectHash, target);
105
100
 
106
101
  // Step 4: Extract and save secrets
107
102
  if (!options.skipSecrets) {
108
103
  console.log(chalk.cyan('🔐 Extracting secrets...'));
109
- const secrets = await this.secretsManager.extractMCPSecrets(
110
- projectPath,
111
- projectHash,
112
- target
113
- );
104
+ const secrets = await this.secretsManager.extractMCPSecrets(projectPath, projectHash, target);
114
105
 
115
106
  if (Object.keys(secrets.servers).length > 0) {
116
107
  await this.secretsManager.saveSecrets(projectHash, secrets);
117
- console.log(
118
- chalk.green(` ✓ Saved ${Object.keys(secrets.servers).length} MCP secret(s)`)
119
- );
108
+ console.log(chalk.green(` ✓ Saved ${Object.keys(secrets.servers).length} MCP secret(s)`));
120
109
  }
121
110
  }
122
111
 
123
112
  // Step 5: Start session (use backup's sessionId to ensure consistency)
124
- const { session, isFirstSession } = await this.sessionManager.startSession(
113
+ const { session } = await this.sessionManager.startSession(
125
114
  projectPath,
126
115
  projectHash,
127
116
  target,
@@ -167,30 +156,31 @@ export class FlowExecutor {
167
156
  console.log(chalk.green('\n✓ Flow environment ready!\n'));
168
157
  }
169
158
 
159
+ /**
160
+ * Resolve target from ID string to Target object
161
+ */
162
+ private resolveTarget(targetId: string): Target {
163
+ const targetOption = targetManager.getTarget(targetId);
164
+ if (targetOption._tag === 'None') {
165
+ throw new Error(`Unknown target: ${targetId}`);
166
+ }
167
+ return targetOption.value;
168
+ }
169
+
170
170
  /**
171
171
  * Clear user settings in replace mode
172
172
  * This ensures a clean slate for Flow's configuration
173
173
  */
174
- private async clearUserSettings(
175
- projectPath: string,
176
- target: 'claude-code' | 'opencode'
177
- ): Promise<void> {
178
- const targetDir = this.projectManager.getTargetConfigDir(projectPath, target);
174
+ private async clearUserSettings(projectPath: string, targetOrId: Target | string): Promise<void> {
175
+ const target = typeof targetOrId === 'string' ? this.resolveTarget(targetOrId) : targetOrId;
179
176
  const fs = await import('node:fs/promises');
180
177
  const path = await import('node:path');
181
178
  const { existsSync } = await import('node:fs');
182
179
 
183
- if (!existsSync(targetDir)) {
184
- return;
185
- }
186
-
187
- // Get directory names for this target
188
- const dirs = target === 'claude-code'
189
- ? { agents: 'agents', commands: 'commands' }
190
- : { agents: 'agent', commands: 'command' };
180
+ // All paths use target.config.* directly (full paths relative to projectPath)
191
181
 
192
- // 1. Clear agents directory (including AGENTS.md rules file)
193
- const agentsDir = path.join(targetDir, dirs.agents);
182
+ // 1. Clear agents directory (including AGENTS.md rules file for Claude Code)
183
+ const agentsDir = path.join(projectPath, target.config.agentDir);
194
184
  if (existsSync(agentsDir)) {
195
185
  const files = await fs.readdir(agentsDir);
196
186
  for (const file of files) {
@@ -198,17 +188,19 @@ export class FlowExecutor {
198
188
  }
199
189
  }
200
190
 
201
- // 2. Clear commands directory
202
- const commandsDir = path.join(targetDir, dirs.commands);
203
- if (existsSync(commandsDir)) {
204
- const files = await fs.readdir(commandsDir);
205
- for (const file of files) {
206
- await fs.unlink(path.join(commandsDir, file));
191
+ // 2. Clear commands directory (if target supports slash commands)
192
+ if (target.config.slashCommandsDir) {
193
+ const commandsDir = path.join(projectPath, target.config.slashCommandsDir);
194
+ if (existsSync(commandsDir)) {
195
+ const files = await fs.readdir(commandsDir);
196
+ for (const file of files) {
197
+ await fs.unlink(path.join(commandsDir, file));
198
+ }
207
199
  }
208
200
  }
209
201
 
210
- // 3. Clear hooks directory
211
- const hooksDir = path.join(targetDir, 'hooks');
202
+ // 3. Clear hooks directory (in configDir)
203
+ const hooksDir = path.join(projectPath, target.config.configDir, 'hooks');
212
204
  if (existsSync(hooksDir)) {
213
205
  const files = await fs.readdir(hooksDir);
214
206
  for (const file of files) {
@@ -216,40 +208,33 @@ export class FlowExecutor {
216
208
  }
217
209
  }
218
210
 
219
- // 4. Clear MCP configuration completely
220
- const configPath = target === 'claude-code'
221
- ? path.join(targetDir, 'settings.json')
222
- : path.join(targetDir, '.mcp.json');
211
+ // 4. Clear MCP configuration using target config
212
+ const configPath = path.join(projectPath, target.config.configFile);
213
+ const mcpPath = target.config.mcpConfigPath;
223
214
 
224
215
  if (existsSync(configPath)) {
225
- if (target === 'claude-code') {
226
- // For Claude Code, clear entire MCP section to remove all user config
227
- const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
228
- if (config.mcp) {
229
- // Remove entire MCP configuration, not just servers
230
- delete config.mcp;
231
- await fs.writeFile(configPath, JSON.stringify(config, null, 2));
232
- }
233
- } else {
234
- // For OpenCode, clear the entire .mcp.json file
235
- await fs.writeFile(configPath, JSON.stringify({ servers: {} }, null, 2));
216
+ const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
217
+ if (config[mcpPath]) {
218
+ // Remove entire MCP configuration section
219
+ delete config[mcpPath];
220
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
236
221
  }
237
222
  }
238
223
 
239
- // 5. Clear AGENTS.md rules file (for OpenCode)
240
- // Claude Code AGENTS.md is already handled in agents directory
241
- if (target === 'opencode') {
242
- const rulesPath = path.join(targetDir, 'AGENTS.md');
224
+ // 5. Clear rules file if target has one defined (for targets like OpenCode)
225
+ // Claude Code puts AGENTS.md in agents directory, handled above
226
+ if (target.config.rulesFile) {
227
+ const rulesPath = path.join(projectPath, target.config.rulesFile);
243
228
  if (existsSync(rulesPath)) {
244
229
  await fs.unlink(rulesPath);
245
230
  }
246
231
  }
247
232
 
248
233
  // 6. Clear single files (output styles like silent.md)
249
- // These are now in the target directory, not project root
234
+ // These are in the configDir
250
235
  const singleFiles = ['silent.md']; // Add other known single files here
251
236
  for (const fileName of singleFiles) {
252
- const filePath = path.join(targetDir, fileName);
237
+ const filePath = path.join(projectPath, target.config.configDir, fileName);
253
238
  if (existsSync(filePath)) {
254
239
  await fs.unlink(filePath);
255
240
  }
@@ -294,13 +279,11 @@ export class FlowExecutor {
294
279
  /**
295
280
  * Show attach summary
296
281
  */
297
- private showAttachSummary(result: any): void {
282
+ private showAttachSummary(result: AttachResult): void {
298
283
  const items = [];
299
284
 
300
285
  if (result.agentsAdded.length > 0) {
301
- items.push(
302
- `${result.agentsAdded.length} agent${result.agentsAdded.length > 1 ? 's' : ''}`
303
- );
286
+ items.push(`${result.agentsAdded.length} agent${result.agentsAdded.length > 1 ? 's' : ''}`);
304
287
  }
305
288
 
306
289
  if (result.commandsAdded.length > 0) {
@@ -316,9 +299,7 @@ export class FlowExecutor {
316
299
  }
317
300
 
318
301
  if (result.hooksAdded.length > 0) {
319
- items.push(
320
- `${result.hooksAdded.length} hook${result.hooksAdded.length > 1 ? 's' : ''}`
321
- );
302
+ items.push(`${result.hooksAdded.length} hook${result.hooksAdded.length > 1 ? 's' : ''}`);
322
303
  }
323
304
 
324
305
  if (result.rulesAppended) {
@@ -329,7 +310,8 @@ export class FlowExecutor {
329
310
  console.log(chalk.green(` ✓ Added: ${items.join(', ')}`));
330
311
  }
331
312
 
332
- const overridden = result.agentsOverridden.length +
313
+ const overridden =
314
+ result.agentsOverridden.length +
333
315
  result.commandsOverridden.length +
334
316
  result.mcpServersOverridden.length +
335
317
  result.hooksOverridden.length;
@@ -28,16 +28,14 @@ export interface ByteFormatOptions {
28
28
  export function formatBytes(bytes: number, options: ByteFormatOptions = {}): string {
29
29
  const { decimals = 2, shortUnits = false } = options;
30
30
 
31
- const units = shortUnits
32
- ? ['B', 'KB', 'MB', 'GB', 'TB']
33
- : ['Bytes', 'KB', 'MB', 'GB', 'TB'];
31
+ const units = shortUnits ? ['B', 'KB', 'MB', 'GB', 'TB'] : ['Bytes', 'KB', 'MB', 'GB', 'TB'];
34
32
 
35
33
  if (bytes === 0) {
36
34
  return `0 ${units[0]}`;
37
35
  }
38
36
 
39
37
  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
40
- const value = bytes / Math.pow(1024, i);
38
+ const value = bytes / 1024 ** i;
41
39
 
42
40
  // Format with specified decimal places
43
41
  const formatted = value.toFixed(decimals);
@@ -95,7 +95,7 @@ export const retry = async <T>(
95
95
  ): AsyncResult<T> => {
96
96
  const { maxAttempts, delayMs = 1000, backoff = 2, onRetry } = options;
97
97
 
98
- let lastError: AppError | null = null;
98
+ let lastError: AppError = { type: 'unknown', message: 'No attempts made', code: 'NO_ATTEMPTS' };
99
99
  let currentDelay = delayMs;
100
100
 
101
101
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -117,7 +117,7 @@ export const retry = async <T>(
117
117
  }
118
118
  }
119
119
 
120
- return failure(lastError!);
120
+ return failure(lastError);
121
121
  };
122
122
 
123
123
  /**
@@ -231,8 +231,9 @@ export const memoizeAsync = <Args extends any[], T>(
231
231
  return (...args: Args): AsyncResult<T, AppError> => {
232
232
  const key = keyFn ? keyFn(...args) : JSON.stringify(args);
233
233
 
234
- if (cache.has(key)) {
235
- return cache.get(key)!;
234
+ const cached = cache.get(key);
235
+ if (cached !== undefined) {
236
+ return cached;
236
237
  }
237
238
 
238
239
  const result = fn(...args);
@@ -81,7 +81,7 @@ export const retry = async <T>(
81
81
  ): Promise<Result<T, AppError>> => {
82
82
  const { maxRetries, delayMs, backoff = 2, onRetry } = options;
83
83
 
84
- let lastError: AppError | null = null;
84
+ let lastError: AppError = { type: 'unknown', message: 'Unknown error', code: 'UNKNOWN' };
85
85
  let currentDelay = delayMs;
86
86
 
87
87
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -103,7 +103,7 @@ export const retry = async <T>(
103
103
  }
104
104
  }
105
105
 
106
- return failure(lastError!);
106
+ return failure(lastError);
107
107
  };
108
108
 
109
109
  /**
@@ -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
  /**