@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
@@ -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
@@ -1,9 +1,9 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { projectSettings } from '../utils/config/settings.js';
5
- import { targetManager } from './target-manager.js';
6
4
  import { ConfigService } from '../services/config-service.js';
5
+ import type { Target } from '../types/target.types.js';
6
+ import { targetManager } from './target-manager.js';
7
7
 
8
8
  const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = path.dirname(__filename);
@@ -28,7 +28,13 @@ export interface ProjectState {
28
28
  lastUpdated: Date | null;
29
29
  }
30
30
 
31
- export type RecommendedAction = 'FULL_INIT' | 'RUN_ONLY' | 'REPAIR' | 'UPGRADE' | 'UPGRADE_TARGET' | 'CLEAN_INIT';
31
+ export type RecommendedAction =
32
+ | 'FULL_INIT'
33
+ | 'RUN_ONLY'
34
+ | 'REPAIR'
35
+ | 'UPGRADE'
36
+ | 'UPGRADE_TARGET'
37
+ | 'CLEAN_INIT';
32
38
 
33
39
  export class StateDetector {
34
40
  private projectPath: string;
@@ -37,6 +43,17 @@ export class StateDetector {
37
43
  this.projectPath = projectPath;
38
44
  }
39
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
+
40
57
  async detect(): Promise<ProjectState> {
41
58
  const state: ProjectState = {
42
59
  initialized: false,
@@ -77,37 +94,16 @@ export class StateDetector {
77
94
  state.outdated = this.isVersionOutdated(state.version, state.latestVersion);
78
95
  }
79
96
 
80
- // Check components based on target
81
- if (state.target === 'opencode') {
82
- // OpenCode uses different directory structure
83
- await this.checkComponent('agents', '.opencode/agent', '*.md', state);
84
- // OpenCode uses AGENTS.md for rules
85
- await this.checkFileComponent('rules', 'AGENTS.md', state);
86
- // OpenCode doesn't have separate hooks directory (hooks config in opencode.jsonc)
87
- state.components.hooks.installed = false;
88
- // OpenCode appends output styles to AGENTS.md
89
- state.components.outputStyles.installed = await this.checkOutputStylesInAGENTS();
90
- await this.checkComponent('slashCommands', '.opencode/command', '*.md', state);
91
- } else {
92
- // Claude Code (default)
93
- await this.checkComponent('agents', '.claude/agents', '*.md', state);
94
-
95
- // Claude Code includes rules and output styles in agent files
96
- // So we mark them as installed if agents are installed
97
- state.components.rules.installed = state.components.agents.installed;
98
- state.components.rules.count = state.components.agents.count;
99
-
100
- state.components.outputStyles.installed = state.components.agents.installed;
101
-
102
- // Check hooks (optional for Claude Code)
103
- await this.checkComponent('hooks', '.claude/hooks', '*.js', state);
104
-
105
- // Check slash commands
106
- await this.checkComponent('slashCommands', '.claude/commands', '*.md', state);
97
+ // Resolve target to get config
98
+ const target = state.target ? this.resolveTarget(state.target) : null;
99
+
100
+ // Check components based on target config
101
+ if (target) {
102
+ await this.checkComponentsForTarget(target, state);
107
103
  }
108
104
 
109
105
  // Check MCP
110
- const mcpConfig = await this.checkMCPConfig(state.target);
106
+ const mcpConfig = await this.checkMCPConfig(target);
111
107
  state.components.mcp.installed = mcpConfig.exists;
112
108
  state.components.mcp.serverCount = mcpConfig.serverCount;
113
109
  state.components.mcp.version = mcpConfig.version;
@@ -121,14 +117,45 @@ export class StateDetector {
121
117
 
122
118
  // Check corruption
123
119
  state.corrupted = await this.checkCorruption(state);
124
-
125
- } catch (error) {
120
+ } catch (_error) {
126
121
  state.corrupted = true;
127
122
  }
128
123
 
129
124
  return state;
130
125
  }
131
126
 
127
+ /**
128
+ * Check components based on target configuration
129
+ */
130
+ private async checkComponentsForTarget(target: Target, state: ProjectState): Promise<void> {
131
+ // Check agents using target's agentDir
132
+ await this.checkComponent('agents', target.config.agentDir, '*.md', state);
133
+
134
+ // Check rules based on target config
135
+ if (target.config.rulesFile) {
136
+ // Target has separate rules file (e.g., OpenCode's AGENTS.md)
137
+ await this.checkFileComponent('rules', target.config.rulesFile, state);
138
+ // Check output styles in rules file
139
+ state.components.outputStyles.installed = await this.checkOutputStylesInFile(
140
+ target.config.rulesFile
141
+ );
142
+ } else {
143
+ // Rules are included in agent files (e.g., Claude Code)
144
+ state.components.rules.installed = state.components.agents.installed;
145
+ state.components.rules.count = state.components.agents.count;
146
+ state.components.outputStyles.installed = state.components.agents.installed;
147
+ }
148
+
149
+ // Check hooks - look for hooks directory in configDir
150
+ const hooksDir = path.join(target.config.configDir, 'hooks');
151
+ await this.checkComponent('hooks', hooksDir, '*.js', state);
152
+
153
+ // Check slash commands using target's slashCommandsDir
154
+ if (target.config.slashCommandsDir) {
155
+ await this.checkComponent('slashCommands', target.config.slashCommandsDir, '*.md', state);
156
+ }
157
+ }
158
+
132
159
  recommendAction(state: ProjectState): RecommendedAction {
133
160
  if (!state.initialized) {
134
161
  return 'FULL_INIT';
@@ -142,8 +169,11 @@ export class StateDetector {
142
169
  return 'UPGRADE';
143
170
  }
144
171
 
145
- if (state.targetVersion && state.targetLatestVersion &&
146
- this.isVersionOutdated(state.targetVersion, state.targetLatestVersion)) {
172
+ if (
173
+ state.targetVersion &&
174
+ state.targetLatestVersion &&
175
+ this.isVersionOutdated(state.targetVersion, state.targetLatestVersion)
176
+ ) {
147
177
  return 'UPGRADE_TARGET';
148
178
  }
149
179
 
@@ -170,8 +200,11 @@ export class StateDetector {
170
200
  explanations.push('Run `bun dev:flow upgrade` to upgrade');
171
201
  }
172
202
 
173
- if (state.targetVersion && state.targetLatestVersion &&
174
- this.isVersionOutdated(state.targetVersion, state.targetLatestVersion)) {
203
+ if (
204
+ state.targetVersion &&
205
+ state.targetLatestVersion &&
206
+ this.isVersionOutdated(state.targetVersion, state.targetLatestVersion)
207
+ ) {
175
208
  explanations.push(`${state.target} update available`);
176
209
  explanations.push(`Run \`bun dev:flow upgrade-target\` to upgrade`);
177
210
  }
@@ -210,7 +243,10 @@ export class StateDetector {
210
243
  ): Promise<void> {
211
244
  try {
212
245
  const fullPath = path.join(this.projectPath, componentPath);
213
- const exists = await fs.access(fullPath).then(() => true).catch(() => false);
246
+ const exists = await fs
247
+ .access(fullPath)
248
+ .then(() => true)
249
+ .catch(() => false);
214
250
 
215
251
  if (!exists) {
216
252
  state.components[componentName].installed = false;
@@ -219,19 +255,30 @@ export class StateDetector {
219
255
 
220
256
  // 计算文件数量
221
257
  const files = await fs.readdir(fullPath).catch(() => []);
222
- const count = pattern === '*.js' ? files.filter(f => f.endsWith('.js')).length :
223
- pattern === '*.md' ? files.filter(f => f.endsWith('.md')).length : files.length;
258
+ const count =
259
+ pattern === '*.js'
260
+ ? files.filter((f) => f.endsWith('.js')).length
261
+ : pattern === '*.md'
262
+ ? files.filter((f) => f.endsWith('.md')).length
263
+ : files.length;
224
264
 
225
265
  // Component is only installed if it has files
226
266
  state.components[componentName].installed = count > 0;
227
267
 
228
- if (componentName === 'agents' || componentName === 'slashCommands' || componentName === 'rules') {
268
+ if (
269
+ componentName === 'agents' ||
270
+ componentName === 'slashCommands' ||
271
+ componentName === 'rules'
272
+ ) {
229
273
  state.components[componentName].count = count;
230
274
  }
231
275
 
232
276
  // 这里可以读取版本信息(如果保存了的话)
233
277
  const versionPath = path.join(fullPath, '.version');
234
- const versionExists = await fs.access(versionPath).then(() => true).catch(() => false);
278
+ const versionExists = await fs
279
+ .access(versionPath)
280
+ .then(() => true)
281
+ .catch(() => false);
235
282
  if (versionExists) {
236
283
  state.components[componentName].version = await fs.readFile(versionPath, 'utf-8');
237
284
  }
@@ -247,7 +294,10 @@ export class StateDetector {
247
294
  ): Promise<void> {
248
295
  try {
249
296
  const fullPath = path.join(this.projectPath, filePath);
250
- const exists = await fs.access(fullPath).then(() => true).catch(() => false);
297
+ const exists = await fs
298
+ .access(fullPath)
299
+ .then(() => true)
300
+ .catch(() => false);
251
301
 
252
302
  state.components[componentName].installed = exists;
253
303
 
@@ -260,53 +310,53 @@ export class StateDetector {
260
310
  }
261
311
  }
262
312
 
263
- private async checkOutputStylesInAGENTS(): Promise<boolean> {
313
+ private async checkOutputStylesInFile(filePath: string): Promise<boolean> {
264
314
  try {
265
- const agentsPath = path.join(this.projectPath, 'AGENTS.md');
266
- const exists = await fs.access(agentsPath).then(() => true).catch(() => false);
315
+ const fullPath = path.join(this.projectPath, filePath);
316
+ const exists = await fs
317
+ .access(fullPath)
318
+ .then(() => true)
319
+ .catch(() => false);
267
320
 
268
321
  if (!exists) {
269
322
  return false;
270
323
  }
271
324
 
272
- // Check if AGENTS.md contains output styles section
273
- const content = await fs.readFile(agentsPath, 'utf-8');
325
+ // Check if file contains output styles section
326
+ const content = await fs.readFile(fullPath, 'utf-8');
274
327
  return content.includes('# Output Styles');
275
328
  } catch {
276
329
  return false;
277
330
  }
278
331
  }
279
332
 
280
- private async checkMCPConfig(target?: string | null): Promise<{ exists: boolean; serverCount: number; version: string | null }> {
333
+ private async checkMCPConfig(
334
+ target?: Target | null
335
+ ): Promise<{ exists: boolean; serverCount: number; version: string | null }> {
281
336
  try {
282
- let mcpPath: string;
283
- let serversKey: string;
284
-
285
- if (target === 'opencode') {
286
- // OpenCode uses opencode.jsonc with mcp key
287
- mcpPath = path.join(this.projectPath, 'opencode.jsonc');
288
- serversKey = 'mcp';
289
- } else {
290
- // Claude Code uses .mcp.json with mcpServers key
291
- mcpPath = path.join(this.projectPath, '.mcp.json');
292
- serversKey = 'mcpServers';
337
+ if (!target) {
338
+ return { exists: false, serverCount: 0, version: null };
293
339
  }
294
340
 
295
- const exists = await fs.access(mcpPath).then(() => true).catch(() => false);
341
+ // Use target config for MCP file path and servers key
342
+ const mcpPath = path.join(this.projectPath, target.config.configFile);
343
+ const serversKey = target.config.mcpConfigPath;
344
+
345
+ const exists = await fs
346
+ .access(mcpPath)
347
+ .then(() => true)
348
+ .catch(() => false);
296
349
 
297
350
  if (!exists) {
298
351
  return { exists: false, serverCount: 0, version: null };
299
352
  }
300
353
 
301
- // Use proper JSONC parser for OpenCode (handles comments)
354
+ // Use target's readConfig method for proper parsing (handles JSONC, etc.)
302
355
  let content: any;
303
- if (target === 'opencode') {
304
- // Import dynamically to avoid circular dependency
305
- const { fileUtils } = await import('../utils/target-utils.js');
306
- const { opencodeTarget } = await import('../targets/opencode.js');
307
- content = await fileUtils.readConfig(opencodeTarget.config, this.projectPath);
308
- } else {
309
- // Claude Code uses plain JSON
356
+ try {
357
+ content = await target.readConfig(this.projectPath);
358
+ } catch {
359
+ // Fallback to plain JSON parsing
310
360
  content = JSON.parse(await fs.readFile(mcpPath, 'utf-8'));
311
361
  }
312
362
 
@@ -322,19 +372,24 @@ export class StateDetector {
322
372
  }
323
373
  }
324
374
 
325
- private async checkTargetVersion(target: string): Promise<{ version: string | null; latestVersion: string | null }> {
375
+ private async checkTargetVersion(
376
+ targetId: string
377
+ ): Promise<{ version: string | null; latestVersion: string | null }> {
326
378
  try {
327
- // 这里可以检查目标平台的版本
328
- // 例如检查 claude CLI 版本或 opencode 版本
329
- if (target === 'claude-code') {
330
- // 检查 claude --version
379
+ const target = this.resolveTarget(targetId);
380
+ if (!target) {
381
+ return { version: null, latestVersion: null };
382
+ }
383
+
384
+ // Check if target has executeCommand (CLI-based target)
385
+ // Only CLI targets like claude-code have version checking capability
386
+ if (target.executeCommand && target.id === 'claude-code') {
331
387
  const { exec } = await import('node:child_process');
332
388
  const { promisify } = await import('node:util');
333
389
  const execAsync = promisify(exec);
334
390
 
335
391
  try {
336
392
  const { stdout } = await execAsync('claude --version');
337
- // 解析版本号
338
393
  const match = stdout.match(/v?(\d+\.\d+\.\d+)/);
339
394
  return {
340
395
  version: match ? match[1] : null,
@@ -366,14 +421,18 @@ export class StateDetector {
366
421
  }
367
422
 
368
423
  private async checkCorruption(state: ProjectState): Promise<boolean> {
369
- // 检查是否存在矛盾的状态
424
+ // Check for contradictory states
370
425
  if (state.initialized && !state.target) {
371
- return true; // 初始化咗但冇 target
426
+ return true; // Initialized but no target
372
427
  }
373
428
 
374
- // 检查必需组件 - only check agents for claude-code
375
- if (state.initialized && state.target === 'claude-code' && !state.components.agents.installed) {
376
- return true; // claude-code 初始化咗但冇 agents
429
+ // Check required components based on target
430
+ if (state.initialized && state.target) {
431
+ const target = this.resolveTarget(state.target);
432
+ // CLI-based targets (category: 'cli') require agents to be installed
433
+ if (target && target.category === 'cli' && !state.components.agents.installed) {
434
+ return true; // CLI target initialized but no agents
435
+ }
377
436
  }
378
437
 
379
438
  return false;