@sylphx/flow 3.13.0 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @sylphx/flow
2
2
 
3
+ ## 3.13.1 (2026-02-04)
4
+
5
+ ### ⚡️ Performance
6
+
7
+ - major performance optimizations ([aaaf25c](https://github.com/SylphxAI/flow/commit/aaaf25cd20555a2fa2a52713ae78c15368565c34))
8
+
9
+ ### ♻️ Refactoring
10
+
11
+ - **auto-upgrade:** fully non-blocking background updates ([eb0a2da](https://github.com/SylphxAI/flow/commit/eb0a2da355ad6418e3c96b19dfe3ee1d6222724c))
12
+ - **auto-upgrade:** remove target CLI upgrade logic - only manage Flow updates ([65ad241](https://github.com/SylphxAI/flow/commit/65ad241e40aaf772b9e9e3a55c8f11693adfefe5))
13
+ - **auto-upgrade:** remove blocking startup check, keep only periodic background check ([4f29c01](https://github.com/SylphxAI/flow/commit/4f29c01ce31bb30457240b9ff90114d3dd03c66e))
14
+
3
15
  ## 3.13.0 (2026-02-04)
4
16
 
5
17
  ### ✨ Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/flow",
3
- "version": "3.13.0",
3
+ "version": "3.13.1",
4
4
  "description": "One CLI to rule them all. Unified orchestration layer for AI coding assistants. Auto-detection, auto-installation, auto-upgrade.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -161,14 +161,15 @@ export async function executeFlowV2(
161
161
  ): Promise<void> {
162
162
  const projectPath = process.cwd();
163
163
 
164
- // Initialize config service early to check for saved preferences
164
+ // Initialize services and load config in parallel
165
165
  const configService = new GlobalConfigService();
166
- await configService.initialize();
167
-
168
- // Step 1: Determine target (silent auto-detect, only prompt when necessary)
169
166
  const targetInstaller = new TargetInstaller(projectPath);
170
- const installedTargets = await targetInstaller.detectInstalledTargets();
171
- const settings = await configService.loadSettings();
167
+
168
+ const [, installedTargets, settings] = await Promise.all([
169
+ configService.initialize(),
170
+ targetInstaller.detectInstalledTargets(),
171
+ configService.loadSettings(),
172
+ ]);
172
173
 
173
174
  let selectedTargetId: string | null = null;
174
175
 
@@ -238,20 +239,8 @@ export async function executeFlowV2(
238
239
  // Show minimal header
239
240
  showHeader(version, targetName);
240
241
 
241
- // Step 2: Auto-upgrade (silent, returns status)
242
- const autoUpgrade = new AutoUpgrade(projectPath);
243
- const upgradeResult = await autoUpgrade.runAutoUpgrade(selectedTargetId);
244
-
245
- // Start periodic background checks (every 30 minutes)
246
- autoUpgrade.startPeriodicCheck(selectedTargetId);
247
-
248
- // Show upgrade notice (minimal - only if upgraded)
249
- if (upgradeResult.flowUpgraded && upgradeResult.flowVersion) {
250
- console.log(chalk.dim(`↑ flow ${upgradeResult.flowVersion.latest}`));
251
- }
252
- if (upgradeResult.targetUpgraded && upgradeResult.targetVersion) {
253
- console.log(chalk.dim(`↑ ${targetName.toLowerCase()} ${upgradeResult.targetVersion.latest}`));
254
- }
242
+ // Step 2: Start background auto-upgrade (non-blocking)
243
+ new AutoUpgrade().start();
255
244
 
256
245
  // Create executor
257
246
  const executor = new FlowExecutor();
@@ -287,9 +276,11 @@ export async function executeFlowV2(
287
276
  agent = enabledAgents.length > 0 ? enabledAgents[0] : 'builder';
288
277
  }
289
278
 
290
- // Load agent content
291
- const enabledRules = await configService.getEnabledRules();
292
- const enabledOutputStyles = await configService.getEnabledOutputStyles();
279
+ // Load agent content (parallel fetch rules and styles)
280
+ const [enabledRules, enabledOutputStyles] = await Promise.all([
281
+ configService.getEnabledRules(),
282
+ configService.getEnabledOutputStyles(),
283
+ ]);
293
284
 
294
285
  const agentContent = await loadAgentContent(
295
286
  agent,
@@ -22,39 +22,41 @@ export class TemplateLoader {
22
22
  }
23
23
 
24
24
  /**
25
- * Load all templates for target
25
+ * Load all templates for target (parallel loading for performance)
26
26
  * Uses flat assets directory structure (no target-specific subdirectories)
27
27
  */
28
28
  async loadTemplates(_target: Target | string): Promise<FlowTemplates> {
29
- const templates: FlowTemplates = {
30
- agents: [],
31
- commands: [],
32
- skills: [],
33
- rules: undefined,
34
- mcpServers: [],
35
- hooks: [],
36
- singleFiles: [],
37
- };
38
-
39
- // Load agents
40
29
  const agentsDir = path.join(this.assetsDir, 'agents');
41
- if (existsSync(agentsDir)) {
42
- templates.agents = await this.loadAgents(agentsDir);
43
- }
44
-
45
- // Load commands (slash-commands directory)
46
30
  const commandsDir = path.join(this.assetsDir, 'slash-commands');
47
- if (existsSync(commandsDir)) {
48
- templates.commands = await this.loadCommands(commandsDir);
49
- }
50
-
51
- // Load skills (skills/<domain>/SKILL.md structure)
52
31
  const skillsDir = path.join(this.assetsDir, 'skills');
53
- if (existsSync(skillsDir)) {
54
- templates.skills = await this.loadSkills(skillsDir);
55
- }
32
+ const mcpConfigPath = path.join(this.assetsDir, 'mcp-servers.json');
33
+ const outputStylesDir = path.join(this.assetsDir, 'output-styles');
56
34
 
57
- // Load rules (check multiple possible locations)
35
+ // Load all directories in parallel
36
+ const [agents, commands, skills, mcpServers, singleFiles, rules] = await Promise.all([
37
+ existsSync(agentsDir) ? this.loadAgents(agentsDir) : [],
38
+ existsSync(commandsDir) ? this.loadCommands(commandsDir) : [],
39
+ existsSync(skillsDir) ? this.loadSkills(skillsDir) : [],
40
+ existsSync(mcpConfigPath) ? this.loadMCPServers(mcpConfigPath) : [],
41
+ existsSync(outputStylesDir) ? this.loadSingleFiles(outputStylesDir) : [],
42
+ this.loadRules(),
43
+ ]);
44
+
45
+ return {
46
+ agents,
47
+ commands,
48
+ skills,
49
+ rules,
50
+ mcpServers,
51
+ hooks: [],
52
+ singleFiles,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Load rules from possible locations
58
+ */
59
+ private async loadRules(): Promise<string | undefined> {
58
60
  const rulesLocations = [
59
61
  path.join(this.assetsDir, 'rules', 'AGENTS.md'),
60
62
  path.join(this.assetsDir, 'AGENTS.md'),
@@ -62,92 +64,67 @@ export class TemplateLoader {
62
64
 
63
65
  for (const rulesPath of rulesLocations) {
64
66
  if (existsSync(rulesPath)) {
65
- templates.rules = await fs.readFile(rulesPath, 'utf-8');
66
- break;
67
+ return fs.readFile(rulesPath, 'utf-8');
67
68
  }
68
69
  }
69
-
70
- // Load MCP servers (if any)
71
- const mcpConfigPath = path.join(this.assetsDir, 'mcp-servers.json');
72
- if (existsSync(mcpConfigPath)) {
73
- templates.mcpServers = await this.loadMCPServers(mcpConfigPath);
74
- }
75
-
76
- // Load output styles (single files)
77
- const outputStylesDir = path.join(this.assetsDir, 'output-styles');
78
- if (existsSync(outputStylesDir)) {
79
- templates.singleFiles = await this.loadSingleFiles(outputStylesDir);
80
- }
81
-
82
- return templates;
70
+ return undefined;
83
71
  }
84
72
 
85
73
  /**
86
- * Load agents from directory
74
+ * Load agents from directory (parallel file reads)
87
75
  */
88
76
  private async loadAgents(agentsDir: string): Promise<Array<{ name: string; content: string }>> {
89
- const agents = [];
90
77
  const files = await fs.readdir(agentsDir);
91
-
92
- for (const file of files) {
93
- if (!file.endsWith('.md')) {
94
- continue;
95
- }
96
-
97
- const content = await fs.readFile(path.join(agentsDir, file), 'utf-8');
98
- agents.push({ name: file, content });
99
- }
100
-
101
- return agents;
78
+ const mdFiles = files.filter((f) => f.endsWith('.md'));
79
+
80
+ return Promise.all(
81
+ mdFiles.map(async (file) => ({
82
+ name: file,
83
+ content: await fs.readFile(path.join(agentsDir, file), 'utf-8'),
84
+ }))
85
+ );
102
86
  }
103
87
 
104
88
  /**
105
- * Load commands/modes from directory
89
+ * Load commands/modes from directory (parallel file reads)
106
90
  */
107
91
  private async loadCommands(
108
92
  commandsDir: string
109
93
  ): Promise<Array<{ name: string; content: string }>> {
110
- const commands = [];
111
94
  const files = await fs.readdir(commandsDir);
112
-
113
- for (const file of files) {
114
- if (!file.endsWith('.md')) {
115
- continue;
116
- }
117
-
118
- const content = await fs.readFile(path.join(commandsDir, file), 'utf-8');
119
- commands.push({ name: file, content });
120
- }
121
-
122
- return commands;
95
+ const mdFiles = files.filter((f) => f.endsWith('.md'));
96
+
97
+ return Promise.all(
98
+ mdFiles.map(async (file) => ({
99
+ name: file,
100
+ content: await fs.readFile(path.join(commandsDir, file), 'utf-8'),
101
+ }))
102
+ );
123
103
  }
124
104
 
125
105
  /**
126
- * Load skills from directory
106
+ * Load skills from directory (parallel loading)
127
107
  * Skills are stored as <domain>/SKILL.md subdirectories
128
108
  */
129
109
  private async loadSkills(skillsDir: string): Promise<Array<{ name: string; content: string }>> {
130
- const skills = [];
131
110
  const domains = await fs.readdir(skillsDir);
132
111
 
133
- for (const domain of domains) {
134
- const domainPath = path.join(skillsDir, domain);
135
- const stat = await fs.stat(domainPath);
112
+ const results = await Promise.all(
113
+ domains.map(async (domain) => {
114
+ const domainPath = path.join(skillsDir, domain);
115
+ const stat = await fs.stat(domainPath);
136
116
 
137
- if (!stat.isDirectory()) {
138
- continue;
139
- }
117
+ if (!stat.isDirectory()) return null;
118
+
119
+ const skillFile = path.join(domainPath, 'SKILL.md');
120
+ if (!existsSync(skillFile)) return null;
140
121
 
141
- // Look for SKILL.md in each domain directory
142
- const skillFile = path.join(domainPath, 'SKILL.md');
143
- if (existsSync(skillFile)) {
144
122
  const content = await fs.readFile(skillFile, 'utf-8');
145
- // Name includes subdirectory: "auth/SKILL.md"
146
- skills.push({ name: `${domain}/SKILL.md`, content });
147
- }
148
- }
123
+ return { name: `${domain}/SKILL.md`, content };
124
+ })
125
+ );
149
126
 
150
- return skills;
127
+ return results.filter((r): r is { name: string; content: string } => r !== null);
151
128
  }
152
129
 
153
130
  /**
@@ -166,25 +143,26 @@ export class TemplateLoader {
166
143
  }
167
144
 
168
145
  /**
169
- * Load single files (CLAUDE.md, .cursorrules, etc.)
146
+ * Load single files (parallel loading)
170
147
  */
171
148
  private async loadSingleFiles(
172
149
  singleFilesDir: string
173
150
  ): Promise<Array<{ path: string; content: string }>> {
174
- const files = [];
175
151
  const entries = await fs.readdir(singleFilesDir);
176
152
 
177
- for (const entry of entries) {
178
- const filePath = path.join(singleFilesDir, entry);
179
- const stat = await fs.stat(filePath);
153
+ const results = await Promise.all(
154
+ entries.map(async (entry) => {
155
+ const filePath = path.join(singleFilesDir, entry);
156
+ const stat = await fs.stat(filePath);
157
+
158
+ if (!stat.isFile()) return null;
180
159
 
181
- if (stat.isFile()) {
182
160
  const content = await fs.readFile(filePath, 'utf-8');
183
- files.push({ path: entry, content });
184
- }
185
- }
161
+ return { path: entry, content };
162
+ })
163
+ );
186
164
 
187
- return files;
165
+ return results.filter((r): r is { path: string; content: string } => r !== null);
188
166
  }
189
167
 
190
168
  /**
package/src/index.ts CHANGED
@@ -4,9 +4,6 @@
4
4
  * AI-powered development flow management
5
5
  */
6
6
 
7
- import { readFileSync } from 'node:fs';
8
- import { dirname, join } from 'node:path';
9
- import { fileURLToPath } from 'node:url';
10
7
  import { Command } from 'commander';
11
8
  import { executeFlow } from './commands/flow/execute-v2.js';
12
9
  import {
@@ -20,13 +17,10 @@ import {
20
17
  import { hookCommand } from './commands/hook-command.js';
21
18
  import { settingsCommand } from './commands/settings-command.js';
22
19
  import { UserCancelledError } from './utils/errors.js';
20
+ // @ts-expect-error - Bun resolves JSON imports
21
+ import pkg from '../package.json';
23
22
 
24
- // Read version from package.json
25
- const __filename = fileURLToPath(import.meta.url);
26
- const __dirname = dirname(__filename);
27
- const packageJsonPath = join(__dirname, '..', 'package.json');
28
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
29
- const VERSION = packageJson.version;
23
+ const VERSION = pkg.version;
30
24
 
31
25
  /**
32
26
  * Create the main CLI application with enhanced Commander.js configuration
@@ -1,463 +1,189 @@
1
1
  /**
2
2
  * Auto-Upgrade Service
3
- * Non-blocking version check with cache
4
- * Checks in background, shows result on next run
3
+ * Fully non-blocking background updates
4
+ * Only manages Flow updates - target CLIs manage their own updates
5
5
  */
6
6
 
7
7
  import { exec } from 'node:child_process';
8
- import { existsSync } from 'node:fs';
9
8
  import fs from 'node:fs/promises';
10
9
  import os from 'node:os';
11
10
  import path from 'node:path';
12
11
  import { fileURLToPath } from 'node:url';
13
12
  import { promisify } from 'node:util';
14
- import chalk from 'chalk';
15
- import ora from 'ora';
16
- import { detectPackageManager, getUpgradeCommand } from '../utils/package-manager-detector.js';
17
- import { TargetInstaller } from './target-installer.js';
13
+ import { getUpgradeCommand } from '../utils/package-manager-detector.js';
18
14
 
19
15
  const __filename = fileURLToPath(import.meta.url);
20
16
  const __dirname = path.dirname(__filename);
21
17
 
22
- // Version info file (stores last background check result)
23
18
  const VERSION_FILE = path.join(os.homedir(), '.sylphx-flow', 'versions.json');
19
+ const DEFAULT_CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
24
20
 
25
21
  interface VersionInfo {
26
22
  flowLatest?: string;
27
- targetLatest?: Record<string, string>;
28
- targetCurrent?: Record<string, string>;
23
+ lastCheckTime?: number;
29
24
  }
30
25
 
31
26
  const execAsync = promisify(exec);
32
27
 
33
- export interface UpgradeStatus {
34
- flowNeedsUpgrade: boolean;
35
- targetNeedsUpgrade: boolean;
36
- flowVersion: { current: string; latest: string } | null;
37
- targetVersion: { current: string; latest: string } | null;
38
- }
39
-
40
- export interface AutoUpgradeOptions {
41
- verbose?: boolean;
42
- skipFlow?: boolean;
43
- skipTarget?: boolean;
44
- }
45
-
46
- // Default interval: 30 minutes
47
- const DEFAULT_CHECK_INTERVAL_MS = 30 * 60 * 1000;
48
-
49
28
  export class AutoUpgrade {
50
- private projectPath: string;
51
- private options: AutoUpgradeOptions;
52
- private targetInstaller: TargetInstaller;
53
29
  private periodicCheckInterval: NodeJS.Timeout | null = null;
54
30
 
55
- constructor(projectPath: string = process.cwd(), options: AutoUpgradeOptions = {}) {
56
- this.projectPath = projectPath;
57
- this.options = options;
58
- this.targetInstaller = new TargetInstaller(projectPath);
59
- }
60
-
61
31
  /**
62
- * Read version info from last background check
32
+ * Start background update service
33
+ * Runs first check immediately (if not recently checked), then every intervalMs
34
+ * All checks and upgrades are non-blocking
63
35
  */
64
- private async readVersionInfo(): Promise<VersionInfo | null> {
65
- try {
66
- if (!existsSync(VERSION_FILE)) {
67
- return null;
68
- }
69
- const data = await fs.readFile(VERSION_FILE, 'utf-8');
70
- return JSON.parse(data);
71
- } catch {
72
- return null;
73
- }
74
- }
36
+ start(intervalMs: number = DEFAULT_CHECK_INTERVAL_MS): void {
37
+ this.stop();
75
38
 
76
- /**
77
- * Write version info
78
- */
79
- private async writeVersionInfo(info: VersionInfo): Promise<void> {
80
- try {
81
- const dir = path.dirname(VERSION_FILE);
82
- await fs.mkdir(dir, { recursive: true });
83
- await fs.writeFile(VERSION_FILE, JSON.stringify(info, null, 2));
84
- } catch {
85
- // Silent fail
86
- }
87
- }
39
+ // First check immediately (non-blocking) - skips if recently checked
40
+ this.checkAndUpgrade(intervalMs);
88
41
 
89
- /**
90
- * Get current Flow version from package.json (instant, local file)
91
- */
92
- private async getCurrentFlowVersion(): Promise<string> {
93
- try {
94
- const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
95
- const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
96
- return packageJson.version;
97
- } catch {
98
- return 'unknown';
99
- }
42
+ // Then periodic checks
43
+ this.periodicCheckInterval = setInterval(() => {
44
+ this.checkAndUpgrade(intervalMs);
45
+ }, intervalMs);
46
+
47
+ // Don't prevent process from exiting
48
+ this.periodicCheckInterval.unref();
100
49
  }
101
50
 
102
51
  /**
103
- * Check for available upgrades (instant, reads from last background check)
104
- * Background check runs every time for fresh data next run
52
+ * Stop background update service
105
53
  */
106
- async checkForUpgrades(targetId?: string): Promise<UpgradeStatus> {
107
- const info = await this.readVersionInfo();
108
- const currentVersion = await this.getCurrentFlowVersion();
109
-
110
- // Trigger background check for next run (non-blocking, every time)
111
- this.checkInBackground(targetId);
112
-
113
- // No previous check = no upgrade info yet
114
- if (!info) {
115
- return {
116
- flowNeedsUpgrade: false,
117
- targetNeedsUpgrade: false,
118
- flowVersion: null,
119
- targetVersion: null,
120
- };
121
- }
122
-
123
- // Check if Flow needs upgrade
124
- const flowVersion =
125
- info.flowLatest && info.flowLatest !== currentVersion
126
- ? { current: currentVersion, latest: info.flowLatest }
127
- : null;
128
-
129
- // Check if target needs upgrade
130
- let targetVersion: { current: string; latest: string } | null = null;
131
- if (targetId && info.targetLatest?.[targetId] && info.targetCurrent?.[targetId]) {
132
- const current = info.targetCurrent[targetId];
133
- const latest = info.targetLatest[targetId];
134
- if (current !== latest) {
135
- targetVersion = { current, latest };
136
- }
54
+ stop(): void {
55
+ if (this.periodicCheckInterval) {
56
+ clearInterval(this.periodicCheckInterval);
57
+ this.periodicCheckInterval = null;
137
58
  }
138
-
139
- return {
140
- flowNeedsUpgrade: !!flowVersion,
141
- targetNeedsUpgrade: !!targetVersion,
142
- flowVersion,
143
- targetVersion,
144
- };
145
59
  }
146
60
 
147
61
  /**
148
- * Check versions in background (non-blocking)
149
- * Runs every time, updates info for next run
62
+ * Check for updates and upgrade if available (non-blocking)
63
+ * Skips if checked within intervalMs
64
+ * Fire and forget - errors are silently ignored
150
65
  */
151
- private checkInBackground(targetId?: string): void {
152
- // Fire and forget - don't await
153
- this.performBackgroundCheck(targetId).catch(() => {
154
- // Silent fail
66
+ private checkAndUpgrade(intervalMs: number = DEFAULT_CHECK_INTERVAL_MS): void {
67
+ this.shouldCheck(intervalMs).then((shouldCheck) => {
68
+ if (shouldCheck) {
69
+ this.performCheck().catch(() => {
70
+ // Silent fail
71
+ });
72
+ }
155
73
  });
156
74
  }
157
75
 
158
76
  /**
159
- * Perform the actual version check (called in background)
77
+ * Check if enough time has passed since last check
160
78
  */
161
- private async performBackgroundCheck(targetId?: string): Promise<void> {
162
- const oldInfo = await this.readVersionInfo();
163
-
164
- const newInfo: VersionInfo = {
165
- targetLatest: oldInfo?.targetLatest || {},
166
- targetCurrent: oldInfo?.targetCurrent || {},
167
- };
168
-
169
- // Check Flow version from npm (with timeout)
79
+ private async shouldCheck(intervalMs: number): Promise<boolean> {
170
80
  try {
171
- const { stdout } = await execAsync('npm view @sylphx/flow version', { timeout: 5000 });
172
- newInfo.flowLatest = stdout.trim();
81
+ const info = await this.readVersionInfo();
82
+ if (!info?.lastCheckTime) return true;
83
+ return Date.now() - info.lastCheckTime >= intervalMs;
173
84
  } catch {
174
- // Keep old value if check fails
175
- newInfo.flowLatest = oldInfo?.flowLatest;
176
- }
177
-
178
- // Check target version from npm and local (with timeout)
179
- if (targetId) {
180
- const installation = this.targetInstaller.getInstallationInfo(targetId);
181
- if (installation) {
182
- // Check latest version from npm
183
- try {
184
- const { stdout } = await execAsync(`npm view ${installation.package} version`, {
185
- timeout: 5000,
186
- });
187
- newInfo.targetLatest = newInfo.targetLatest || {};
188
- newInfo.targetLatest[targetId] = stdout.trim();
189
- } catch {
190
- // Keep old value
191
- }
192
-
193
- // Check current installed version (local command)
194
- try {
195
- const { stdout } = await execAsync(installation.checkCommand, { timeout: 5000 });
196
- const match = stdout.match(/v?(\d+\.\d+\.\d+)/);
197
- if (match) {
198
- newInfo.targetCurrent = newInfo.targetCurrent || {};
199
- newInfo.targetCurrent[targetId] = match[1];
200
- }
201
- } catch {
202
- // Keep old value
203
- }
204
- }
85
+ return true;
205
86
  }
206
-
207
- await this.writeVersionInfo(newInfo);
208
87
  }
209
88
 
210
89
  /**
211
- * Detect which package manager was used to install Flow globally
212
- * by checking the executable path
90
+ * Read version info from disk
213
91
  */
214
- private async detectFlowPackageManager(): Promise<'bun' | 'npm' | 'pnpm' | 'yarn'> {
92
+ private async readVersionInfo(): Promise<VersionInfo | null> {
215
93
  try {
216
- const { stdout } = await execAsync('which flow || where flow');
217
- const flowPath = stdout.trim().toLowerCase();
218
-
219
- if (flowPath.includes('bun')) {
220
- return 'bun';
221
- }
222
- if (flowPath.includes('pnpm')) {
223
- return 'pnpm';
224
- }
225
- if (flowPath.includes('yarn')) {
226
- return 'yarn';
227
- }
94
+ const data = await fs.readFile(VERSION_FILE, 'utf-8');
95
+ return JSON.parse(data);
228
96
  } catch {
229
- // Fall through to default
97
+ return null;
230
98
  }
231
-
232
- // Default to bun as it's the recommended install method
233
- return 'bun';
234
99
  }
235
100
 
236
101
  /**
237
- * Upgrade Flow to latest version using the package manager that installed it
238
- * @returns True if upgrade successful, false otherwise
102
+ * Perform version check and upgrade if needed
239
103
  */
240
- async upgradeFlow(): Promise<boolean> {
241
- const spinner = ora('Upgrading Flow...').start();
242
-
243
- try {
244
- // Detect which package manager was used to install Flow
245
- const flowPm = await this.detectFlowPackageManager();
246
- const upgradeCmd = getUpgradeCommand('@sylphx/flow', flowPm);
247
-
248
- if (this.options.verbose) {
249
- console.log(chalk.dim(` Using ${flowPm}: ${upgradeCmd}`));
250
- }
251
-
252
- await execAsync(upgradeCmd);
253
-
254
- spinner.succeed(chalk.green('✓ Flow upgraded (new version used on next run)'));
255
- return true;
256
- } catch (error) {
257
- spinner.fail(chalk.red('✗ Flow upgrade failed'));
104
+ private async performCheck(): Promise<void> {
105
+ const currentVersion = await this.getCurrentVersion();
106
+ const latestVersion = await this.fetchLatestVersion();
107
+
108
+ // Save check time regardless of result
109
+ await this.saveVersionInfo({
110
+ flowLatest: latestVersion || undefined,
111
+ lastCheckTime: Date.now(),
112
+ });
258
113
 
259
- if (this.options.verbose) {
260
- console.error(error);
261
- }
114
+ if (!latestVersion) return;
262
115
 
263
- return false;
116
+ // Upgrade if newer version available
117
+ if (latestVersion !== currentVersion) {
118
+ await this.upgrade();
264
119
  }
265
120
  }
266
121
 
267
122
  /**
268
- * Upgrade target CLI to latest version
269
- * Tries built-in upgrade command first, falls back to package manager
270
- * @param targetId - Target CLI ID to upgrade
271
- * @returns True if upgrade successful, false otherwise
123
+ * Get current Flow version from package.json
272
124
  */
273
- async upgradeTarget(targetId: string): Promise<boolean> {
274
- const installation = this.targetInstaller.getInstallationInfo(targetId);
275
- if (!installation) {
276
- return false;
277
- }
278
-
279
- const packageManager = detectPackageManager(this.projectPath);
280
- const spinner = ora(`Upgrading ${installation.name}...`).start();
281
-
125
+ private async getCurrentVersion(): Promise<string> {
282
126
  try {
283
- // For Claude Code, use built-in update command if available
284
- if (targetId === 'claude-code') {
285
- try {
286
- await execAsync('claude update');
287
- spinner.succeed(chalk.green(`✓ ${installation.name} upgraded`));
288
- return true;
289
- } catch {
290
- // Fall back to npm upgrade
291
- }
292
- }
293
-
294
- // For OpenCode, use built-in upgrade command if available
295
- if (targetId === 'opencode') {
296
- try {
297
- await execAsync('opencode upgrade');
298
- spinner.succeed(chalk.green(`✓ ${installation.name} upgraded`));
299
- return true;
300
- } catch {
301
- // Fall back to npm upgrade
302
- }
303
- }
304
-
305
- // Fall back to npm/bun/pnpm/yarn upgrade
306
- const upgradeCmd = getUpgradeCommand(installation.package, packageManager);
307
- await execAsync(upgradeCmd);
308
-
309
- spinner.succeed(chalk.green(`✓ ${installation.name} upgraded`));
310
- return true;
311
- } catch (error) {
312
- spinner.fail(chalk.red(`✗ ${installation.name} upgrade failed`));
313
-
314
- if (this.options.verbose) {
315
- console.error(error);
316
- }
317
-
318
- return false;
319
- }
320
- }
321
-
322
- /**
323
- * Run auto-upgrade check and upgrade if needed (silent)
324
- * @param targetId - Optional target CLI ID to check and upgrade
325
- * @returns Upgrade result with status info
326
- */
327
- async runAutoUpgrade(targetId?: string): Promise<{
328
- flowUpgraded: boolean;
329
- flowVersion?: { current: string; latest: string };
330
- targetUpgraded: boolean;
331
- targetVersion?: { current: string; latest: string };
332
- }> {
333
- const status = await this.checkForUpgrades(targetId);
334
- const result = {
335
- flowUpgraded: false,
336
- flowVersion: status.flowVersion ?? undefined,
337
- targetUpgraded: false,
338
- targetVersion: status.targetVersion ?? undefined,
339
- };
340
-
341
- // Perform upgrades silently
342
- if (status.flowNeedsUpgrade) {
343
- result.flowUpgraded = await this.upgradeFlowSilent();
344
- }
345
-
346
- if (status.targetNeedsUpgrade && targetId) {
347
- result.targetUpgraded = await this.upgradeTargetSilent(targetId);
127
+ const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
128
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
129
+ return packageJson.version;
130
+ } catch {
131
+ return 'unknown';
348
132
  }
349
-
350
- return result;
351
133
  }
352
134
 
353
135
  /**
354
- * Upgrade Flow silently (no spinner/output)
136
+ * Fetch latest version from npm registry
355
137
  */
356
- private async upgradeFlowSilent(): Promise<boolean> {
138
+ private async fetchLatestVersion(): Promise<string | null> {
357
139
  try {
358
- const flowPm = await this.detectFlowPackageManager();
359
- const upgradeCmd = getUpgradeCommand('@sylphx/flow', flowPm);
360
- await execAsync(upgradeCmd);
361
- return true;
140
+ const { stdout } = await execAsync('npm view @sylphx/flow version', { timeout: 5000 });
141
+ return stdout.trim();
362
142
  } catch {
363
- return false;
143
+ return null;
364
144
  }
365
145
  }
366
146
 
367
147
  /**
368
- * Upgrade target CLI silently (no spinner/output)
148
+ * Detect package manager used to install Flow
369
149
  */
370
- private async upgradeTargetSilent(targetId: string): Promise<boolean> {
371
- const installation = this.targetInstaller.getInstallationInfo(targetId);
372
- if (!installation) {
373
- return false;
374
- }
375
-
150
+ private async detectPackageManager(): Promise<'bun' | 'npm' | 'pnpm' | 'yarn'> {
376
151
  try {
377
- if (targetId === 'claude-code') {
378
- try {
379
- await execAsync('claude update');
380
- return true;
381
- } catch {
382
- // Fall back to npm
383
- }
384
- }
385
-
386
- if (targetId === 'opencode') {
387
- try {
388
- await execAsync('opencode upgrade');
389
- return true;
390
- } catch {
391
- // Fall back to npm
392
- }
393
- }
152
+ const { stdout } = await execAsync('which flow || where flow');
153
+ const flowPath = stdout.trim().toLowerCase();
394
154
 
395
- const packageManager = detectPackageManager(this.projectPath);
396
- const upgradeCmd = getUpgradeCommand(installation.package, packageManager);
397
- await execAsync(upgradeCmd);
398
- return true;
155
+ if (flowPath.includes('bun')) return 'bun';
156
+ if (flowPath.includes('pnpm')) return 'pnpm';
157
+ if (flowPath.includes('yarn')) return 'yarn';
399
158
  } catch {
400
- return false;
159
+ // Fall through
401
160
  }
161
+ return 'bun'; // Default
402
162
  }
403
163
 
404
164
  /**
405
- * Start periodic background checks for updates
406
- * Runs every intervalMs (default 30 minutes)
407
- * Silently upgrades if updates are available
408
- * @param targetId - Optional target CLI ID to check
409
- * @param intervalMs - Check interval in milliseconds (default 30 minutes)
410
- */
411
- startPeriodicCheck(targetId?: string, intervalMs: number = DEFAULT_CHECK_INTERVAL_MS): void {
412
- // Clear any existing interval
413
- this.stopPeriodicCheck();
414
-
415
- // Start periodic check
416
- this.periodicCheckInterval = setInterval(() => {
417
- this.performPeriodicUpgrade(targetId).catch(() => {
418
- // Silent fail
419
- });
420
- }, intervalMs);
421
-
422
- // Don't prevent process from exiting
423
- this.periodicCheckInterval.unref();
424
- }
425
-
426
- /**
427
- * Stop periodic background checks
165
+ * Upgrade Flow to latest version
428
166
  */
429
- stopPeriodicCheck(): void {
430
- if (this.periodicCheckInterval) {
431
- clearInterval(this.periodicCheckInterval);
432
- this.periodicCheckInterval = null;
167
+ private async upgrade(): Promise<void> {
168
+ try {
169
+ const pm = await this.detectPackageManager();
170
+ const cmd = getUpgradeCommand('@sylphx/flow', pm);
171
+ await execAsync(cmd);
172
+ } catch {
173
+ // Silent fail
433
174
  }
434
175
  }
435
176
 
436
177
  /**
437
- * Perform periodic upgrade check and silent upgrade
178
+ * Save version info to disk
438
179
  */
439
- private async performPeriodicUpgrade(targetId?: string): Promise<void> {
440
- // Perform background check first (updates version info)
441
- await this.performBackgroundCheck(targetId);
442
-
443
- // Read the fresh version info
444
- const info = await this.readVersionInfo();
445
- if (!info) return;
446
-
447
- const currentVersion = await this.getCurrentFlowVersion();
448
-
449
- // Silently upgrade Flow if needed
450
- if (info.flowLatest && info.flowLatest !== currentVersion) {
451
- await this.upgradeFlowSilent();
452
- }
453
-
454
- // Silently upgrade target if needed
455
- if (targetId && info.targetLatest?.[targetId] && info.targetCurrent?.[targetId]) {
456
- const current = info.targetCurrent[targetId];
457
- const latest = info.targetLatest[targetId];
458
- if (current !== latest) {
459
- await this.upgradeTargetSilent(targetId);
460
- }
180
+ private async saveVersionInfo(info: VersionInfo): Promise<void> {
181
+ try {
182
+ const dir = path.dirname(VERSION_FILE);
183
+ await fs.mkdir(dir, { recursive: true });
184
+ await fs.writeFile(VERSION_FILE, JSON.stringify(info, null, 2));
185
+ } catch {
186
+ // Silent fail
461
187
  }
462
188
  }
463
189
  }
@@ -70,6 +70,7 @@ export interface MCPConfig {
70
70
 
71
71
  export class GlobalConfigService {
72
72
  private flowHomeDir: string;
73
+ private flowConfigCache: FlowConfig | null = null;
73
74
 
74
75
  constructor() {
75
76
  this.flowHomeDir = path.join(os.homedir(), '.sylphx-flow');
@@ -259,13 +260,18 @@ export class GlobalConfigService {
259
260
 
260
261
  /**
261
262
  * Load Flow config (agents, rules, output styles)
263
+ * Cached for performance - call invalidateFlowConfigCache() after saving
262
264
  */
263
265
  async loadFlowConfig(): Promise<FlowConfig> {
266
+ if (this.flowConfigCache) {
267
+ return this.flowConfigCache;
268
+ }
269
+
264
270
  const configPath = this.getFlowConfigPath();
265
271
 
266
272
  if (!existsSync(configPath)) {
267
273
  // Default: all agents, all rules, all output styles enabled
268
- return {
274
+ this.flowConfigCache = {
269
275
  version: '1.0.0',
270
276
  agents: {
271
277
  builder: { enabled: true },
@@ -279,10 +285,19 @@ export class GlobalConfigService {
279
285
  },
280
286
  outputStyles: {},
281
287
  };
288
+ return this.flowConfigCache;
282
289
  }
283
290
 
284
291
  const data = await fs.readFile(configPath, 'utf-8');
285
- return JSON.parse(data);
292
+ this.flowConfigCache = JSON.parse(data);
293
+ return this.flowConfigCache;
294
+ }
295
+
296
+ /**
297
+ * Invalidate flow config cache (call after saving)
298
+ */
299
+ invalidateFlowConfigCache(): void {
300
+ this.flowConfigCache = null;
286
301
  }
287
302
 
288
303
  /**
@@ -292,6 +307,7 @@ export class GlobalConfigService {
292
307
  await this.initialize();
293
308
  const configPath = this.getFlowConfigPath();
294
309
  await fs.writeFile(configPath, JSON.stringify(config, null, 2));
310
+ this.flowConfigCache = config; // Update cache
295
311
  }
296
312
 
297
313
  /**