agileflow 2.88.0 → 2.89.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.
@@ -121,3 +121,17 @@ cat << EOF
121
121
  3. Review docs/02-practices/ for implementation patterns
122
122
  4. Check git log for recent changes
123
123
  EOF
124
+
125
+ # Mark that PreCompact just ran - tells SessionStart to preserve active_commands
126
+ # This prevents the welcome script from clearing commands right after compact
127
+ if [ -f "docs/09-agents/session-state.json" ]; then
128
+ node -e "
129
+ const fs = require('fs');
130
+ const path = 'docs/09-agents/session-state.json';
131
+ try {
132
+ const state = JSON.parse(fs.readFileSync(path, 'utf8'));
133
+ state.last_precompact_at = new Date().toISOString();
134
+ fs.writeFileSync(path, JSON.stringify(state, null, 2) + '\n');
135
+ } catch (e) {}
136
+ " 2>/dev/null
137
+ fi
@@ -228,9 +228,8 @@ function registerSession(nickname = null, threadType = null) {
228
228
  registry.next_id++;
229
229
 
230
230
  const isMain = cwd === ROOT;
231
- const detectedType = threadType && THREAD_TYPES.includes(threadType)
232
- ? threadType
233
- : detectThreadType(null, !isMain);
231
+ const detectedType =
232
+ threadType && THREAD_TYPES.includes(threadType) ? threadType : detectThreadType(null, !isMain);
234
233
 
235
234
  registry.sessions[sessionId] = {
236
235
  path: cwd,
@@ -750,10 +749,10 @@ function getSessionPhase(session) {
750
749
 
751
750
  // Count commits since branch diverged from main
752
751
  const mainBranch = getMainBranch();
753
- const commitCount = execSync(
754
- `git rev-list --count ${mainBranch}..HEAD 2>/dev/null || echo 0`,
755
- { cwd: sessionPath, encoding: 'utf8' }
756
- ).trim();
752
+ const commitCount = execSync(`git rev-list --count ${mainBranch}..HEAD 2>/dev/null || echo 0`, {
753
+ cwd: sessionPath,
754
+ encoding: 'utf8',
755
+ }).trim();
757
756
 
758
757
  const commits = parseInt(commitCount, 10);
759
758
 
@@ -1037,7 +1036,9 @@ function main() {
1037
1036
  if (nickname) registry.sessions[sessionId].nickname = nickname;
1038
1037
  // Ensure thread_type exists (migration for old sessions)
1039
1038
  if (!registry.sessions[sessionId].thread_type) {
1040
- registry.sessions[sessionId].thread_type = registry.sessions[sessionId].is_main ? 'base' : 'parallel';
1039
+ registry.sessions[sessionId].thread_type = registry.sessions[sessionId].is_main
1040
+ ? 'base'
1041
+ : 'parallel';
1041
1042
  }
1042
1043
  writeLock(sessionId, pid);
1043
1044
  } else {
@@ -1218,7 +1219,9 @@ function main() {
1218
1219
  const sessionId = args[2];
1219
1220
  const threadType = args[3];
1220
1221
  if (!sessionId || !threadType) {
1221
- console.log(JSON.stringify({ success: false, error: 'Usage: thread-type set <sessionId> <type>' }));
1222
+ console.log(
1223
+ JSON.stringify({ success: false, error: 'Usage: thread-type set <sessionId> <type>' })
1224
+ );
1222
1225
  return;
1223
1226
  }
1224
1227
  const result = setSessionThreadType(sessionId, threadType);
@@ -1910,7 +1913,10 @@ function getSessionThreadType(sessionId = null) {
1910
1913
  */
1911
1914
  function setSessionThreadType(sessionId, threadType) {
1912
1915
  if (!THREAD_TYPES.includes(threadType)) {
1913
- return { success: false, error: `Invalid thread type: ${threadType}. Valid: ${THREAD_TYPES.join(', ')}` };
1916
+ return {
1917
+ success: false,
1918
+ error: `Invalid thread type: ${threadType}. Valid: ${THREAD_TYPES.join(', ')}`,
1919
+ };
1914
1920
  }
1915
1921
 
1916
1922
  const registry = loadRegistry();
@@ -63,8 +63,8 @@ console.log(`File Being Edited: ${normalizedFile}`);
63
63
  console.log('');
64
64
 
65
65
  // Check if file is within active session path
66
- const isInsideSession = normalizedFile.startsWith(normalizedActive + path.sep) ||
67
- normalizedFile === normalizedActive;
66
+ const isInsideSession =
67
+ normalizedFile.startsWith(normalizedActive + path.sep) || normalizedFile === normalizedActive;
68
68
 
69
69
  if (isInsideSession) {
70
70
  console.log('✅ ALLOWED - File is inside the active session directory');
@@ -130,11 +130,7 @@ async function handleGet(status, key) {
130
130
  const handler = new ErrorHandler('config');
131
131
 
132
132
  if (!key) {
133
- handler.warning(
134
- 'Missing key',
135
- 'Provide a config key to get',
136
- 'npx agileflow config get <key>'
137
- );
133
+ handler.warning('Missing key', 'Provide a config key to get', 'npx agileflow config get <key>');
138
134
  }
139
135
 
140
136
  const validKeys = ['userName', 'ides', 'agileflowFolder', 'docsFolder', 'version'];
@@ -103,11 +103,7 @@ module.exports = {
103
103
 
104
104
  if (!coreResult.success) {
105
105
  const handler = new ErrorHandler('setup');
106
- handler.warning(
107
- 'Core setup failed',
108
- 'Check directory permissions',
109
- 'npx agileflow doctor'
110
- );
106
+ handler.warning('Core setup failed', 'Check directory permissions', 'npx agileflow doctor');
111
107
  }
112
108
 
113
109
  success(`Installed ${coreResult.counts.agents} agents`);
@@ -11,6 +11,7 @@ const ora = require('ora');
11
11
  const yaml = require('js-yaml');
12
12
  const { injectContent } = require('../../lib/content-injector');
13
13
  const { sha256Hex, toPosixPath, safeTimestampForPath } = require('../../lib/utils');
14
+ const { validatePath, PathValidationError } = require('../../../../lib/validate');
14
15
 
15
16
  const TEXT_EXTENSIONS = new Set(['.md', '.yaml', '.yml', '.txt', '.json']);
16
17
 
@@ -191,6 +192,22 @@ class Installer {
191
192
  }
192
193
  }
193
194
 
195
+ /**
196
+ * Validate that a path is within the allowed installation directory.
197
+ * Prevents path traversal attacks when writing files.
198
+ *
199
+ * @param {string} filePath - Path to validate
200
+ * @param {string} baseDir - Allowed base directory
201
+ * @throws {PathValidationError} If path escapes base directory
202
+ */
203
+ validateInstallPath(filePath, baseDir) {
204
+ const result = validatePath(filePath, baseDir, { allowSymlinks: false });
205
+ if (!result.ok) {
206
+ throw result.error;
207
+ }
208
+ return result.resolvedPath;
209
+ }
210
+
194
211
  /**
195
212
  * Copy content from source to destination with placeholder replacement
196
213
  * @param {string} source - Source directory
@@ -201,10 +218,16 @@ class Installer {
201
218
  async copyContent(source, dest, agileflowFolder, policy = null) {
202
219
  const entries = await fs.readdir(source, { withFileTypes: true });
203
220
 
221
+ // Get base directory for validation (agileflowDir from policy or dest itself)
222
+ const baseDir = policy?.agileflowDir || dest;
223
+
204
224
  for (const entry of entries) {
205
225
  const srcPath = path.join(source, entry.name);
206
226
  const destPath = path.join(dest, entry.name);
207
227
 
228
+ // Validate destination path to prevent traversal attacks via malicious filenames
229
+ this.validateInstallPath(destPath, baseDir);
230
+
208
231
  if (entry.isDirectory()) {
209
232
  await fs.ensureDir(destPath);
210
233
  await this.copyContent(srcPath, destPath, agileflowFolder, policy);
@@ -688,18 +711,25 @@ class Installer {
688
711
  * @param {string} srcDir - Source directory
689
712
  * @param {string} destDir - Destination directory
690
713
  * @param {boolean} force - Overwrite existing files
714
+ * @param {string} [baseDir] - Base directory for path validation (defaults to destDir on first call)
691
715
  */
692
- async copyScriptsRecursive(srcDir, destDir, force) {
716
+ async copyScriptsRecursive(srcDir, destDir, force, baseDir = null) {
693
717
  const entries = await fs.readdir(srcDir, { withFileTypes: true });
694
718
 
719
+ // Use destDir as base for validation on first call
720
+ const validationBase = baseDir || destDir;
721
+
695
722
  for (const entry of entries) {
696
723
  const srcPath = path.join(srcDir, entry.name);
697
724
  const destPath = path.join(destDir, entry.name);
698
725
 
726
+ // Validate destination path to prevent traversal via malicious filenames
727
+ this.validateInstallPath(destPath, validationBase);
728
+
699
729
  if (entry.isDirectory()) {
700
730
  // Recursively copy subdirectories
701
731
  await fs.ensureDir(destPath);
702
- await this.copyScriptsRecursive(srcPath, destPath, force);
732
+ await this.copyScriptsRecursive(srcPath, destPath, force, validationBase);
703
733
  } else {
704
734
  // Copy file
705
735
  const destExists = await fs.pathExists(destPath);
@@ -7,6 +7,14 @@
7
7
  const path = require('node:path');
8
8
  const fs = require('fs-extra');
9
9
  const chalk = require('chalk');
10
+ const {
11
+ IdeConfigNotFoundError,
12
+ CommandInstallationError,
13
+ FilePermissionError,
14
+ CleanupError,
15
+ ContentInjectionError,
16
+ withPermissionHandling,
17
+ } = require('../../lib/ide-errors');
10
18
 
11
19
  /**
12
20
  * Base class for IDE-specific setup
@@ -99,9 +107,74 @@ class BaseIdeSetup {
99
107
  throw new Error(`setup() must be implemented by ${this.name} handler`);
100
108
  }
101
109
 
110
+ /**
111
+ * Standard setup flow shared by most IDE installers.
112
+ * Handles cleanup, command/agent installation, and logging.
113
+ *
114
+ * @param {string} projectDir - Project directory
115
+ * @param {string} agileflowDir - AgileFlow installation directory
116
+ * @param {Object} config - Configuration options
117
+ * @param {string} config.targetSubdir - Target subdirectory name under configDir (e.g., 'commands', 'workflows')
118
+ * @param {string} config.agileflowFolder - AgileFlow folder name (e.g., 'agileflow', 'AgileFlow')
119
+ * @param {string} [config.commandLabel='commands'] - Label for commands in output (e.g., 'workflows')
120
+ * @param {string} [config.agentLabel='agents'] - Label for agents in output
121
+ * @returns {Promise<{success: boolean, commands: number, agents: number}>}
122
+ */
123
+ async setupStandard(projectDir, agileflowDir, config) {
124
+ const {
125
+ targetSubdir,
126
+ agileflowFolder,
127
+ commandLabel = 'commands',
128
+ agentLabel = 'agents',
129
+ } = config;
130
+
131
+ console.log(chalk.hex('#e8683a')(` Setting up ${this.displayName}...`));
132
+
133
+ // Clean up old installation first
134
+ await this.cleanup(projectDir);
135
+
136
+ // Create target directory (e.g., .cursor/commands/AgileFlow)
137
+ const ideDir = path.join(projectDir, this.configDir);
138
+ const targetDir = path.join(ideDir, targetSubdir);
139
+ const agileflowTargetDir = path.join(targetDir, agileflowFolder);
140
+
141
+ // Install commands using shared recursive method
142
+ const commandsSource = path.join(agileflowDir, 'commands');
143
+ const commandResult = await this.installCommandsRecursive(
144
+ commandsSource,
145
+ agileflowTargetDir,
146
+ agileflowDir,
147
+ true // Inject dynamic content
148
+ );
149
+
150
+ // Install agents as subdirectory
151
+ const agentsSource = path.join(agileflowDir, 'agents');
152
+ const agentsTargetDir = path.join(agileflowTargetDir, 'agents');
153
+ const agentResult = await this.installCommandsRecursive(
154
+ agentsSource,
155
+ agentsTargetDir,
156
+ agileflowDir,
157
+ false // No dynamic content for agents
158
+ );
159
+
160
+ console.log(chalk.green(` ✓ ${this.displayName} configured:`));
161
+ console.log(chalk.dim(` - ${commandResult.commands} ${commandLabel} installed`));
162
+ console.log(chalk.dim(` - ${agentResult.commands} ${agentLabel} installed`));
163
+ console.log(chalk.dim(` - Path: ${path.relative(projectDir, agileflowTargetDir)}`));
164
+
165
+ return {
166
+ success: true,
167
+ commands: commandResult.commands,
168
+ agents: agentResult.commands,
169
+ ideDir,
170
+ agileflowTargetDir,
171
+ };
172
+ }
173
+
102
174
  /**
103
175
  * Cleanup IDE configuration
104
176
  * @param {string} projectDir - Project directory
177
+ * @throws {CleanupError} If cleanup fails
105
178
  */
106
179
  async cleanup(projectDir) {
107
180
  if (this.configDir) {
@@ -109,10 +182,21 @@ class BaseIdeSetup {
109
182
  for (const folderName of ['agileflow', 'AgileFlow']) {
110
183
  const agileflowPath = path.join(projectDir, this.configDir, 'commands', folderName);
111
184
  if (await fs.pathExists(agileflowPath)) {
112
- await fs.remove(agileflowPath);
113
- console.log(
114
- chalk.dim(` Removed old ${folderName} configuration from ${this.displayName}`)
115
- );
185
+ try {
186
+ await fs.remove(agileflowPath);
187
+ console.log(
188
+ chalk.dim(` Removed old ${folderName} configuration from ${this.displayName}`)
189
+ );
190
+ } catch (error) {
191
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
192
+ throw new CleanupError(
193
+ this.displayName,
194
+ agileflowPath,
195
+ `Permission denied: ${error.message}`
196
+ );
197
+ }
198
+ throw new CleanupError(this.displayName, agileflowPath, error.message);
199
+ }
116
200
  }
117
201
  }
118
202
  }
@@ -140,21 +224,27 @@ class BaseIdeSetup {
140
224
  }
141
225
 
142
226
  /**
143
- * Write a file
227
+ * Write a file with permission error handling
144
228
  * @param {string} filePath - File path
145
229
  * @param {string} content - File content
230
+ * @throws {FilePermissionError} If permission denied
146
231
  */
147
232
  async writeFile(filePath, content) {
148
- await fs.writeFile(filePath, content, 'utf8');
233
+ await withPermissionHandling(this.displayName, filePath, 'write', async () => {
234
+ await fs.writeFile(filePath, content, 'utf8');
235
+ });
149
236
  }
150
237
 
151
238
  /**
152
- * Read a file
239
+ * Read a file with permission error handling
153
240
  * @param {string} filePath - File path
154
241
  * @returns {Promise<string>} File content
242
+ * @throws {FilePermissionError} If permission denied
155
243
  */
156
244
  async readFile(filePath) {
157
- return fs.readFile(filePath, 'utf8');
245
+ return withPermissionHandling(this.displayName, filePath, 'read', async () => {
246
+ return fs.readFile(filePath, 'utf8');
247
+ });
158
248
  }
159
249
 
160
250
  /**
@@ -202,6 +292,8 @@ class BaseIdeSetup {
202
292
  * @param {string} agileflowDir - AgileFlow installation directory (for dynamic content)
203
293
  * @param {boolean} injectDynamic - Whether to inject dynamic content (only for top-level commands)
204
294
  * @returns {Promise<{commands: number, subdirs: number}>} Count of installed items
295
+ * @throws {CommandInstallationError} If command installation fails
296
+ * @throws {FilePermissionError} If permission denied
205
297
  */
206
298
  async installCommandsRecursive(sourceDir, targetDir, agileflowDir, injectDynamic = false) {
207
299
  let commandCount = 0;
@@ -211,7 +303,14 @@ class BaseIdeSetup {
211
303
  return { commands: 0, subdirs: 0 };
212
304
  }
213
305
 
214
- await this.ensureDir(targetDir);
306
+ try {
307
+ await this.ensureDir(targetDir);
308
+ } catch (error) {
309
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
310
+ throw new FilePermissionError(this.displayName, targetDir, 'write');
311
+ }
312
+ throw error;
313
+ }
215
314
 
216
315
  const entries = await fs.readdir(sourceDir, { withFileTypes: true });
217
316
 
@@ -220,19 +319,34 @@ class BaseIdeSetup {
220
319
  const targetPath = path.join(targetDir, entry.name);
221
320
 
222
321
  if (entry.isFile() && entry.name.endsWith('.md')) {
223
- // Read and process .md file
224
- let content = await this.readFile(sourcePath);
322
+ try {
323
+ // Read and process .md file
324
+ let content = await this.readFile(sourcePath);
225
325
 
226
- // Inject dynamic content if enabled (for top-level commands)
227
- if (injectDynamic) {
228
- content = this.injectDynamicContent(content, agileflowDir);
229
- }
326
+ // Inject dynamic content if enabled (for top-level commands)
327
+ if (injectDynamic) {
328
+ try {
329
+ content = this.injectDynamicContent(content, agileflowDir);
330
+ } catch (injectionError) {
331
+ throw new ContentInjectionError(this.displayName, sourcePath, injectionError.message);
332
+ }
333
+ }
230
334
 
231
- // Replace docs/ references with custom folder name
232
- content = this.replaceDocsReferences(content);
335
+ // Replace docs/ references with custom folder name
336
+ content = this.replaceDocsReferences(content);
233
337
 
234
- await this.writeFile(targetPath, content);
235
- commandCount++;
338
+ await this.writeFile(targetPath, content);
339
+ commandCount++;
340
+ } catch (error) {
341
+ // Re-throw typed errors as-is
342
+ if (error.name && error.name.includes('Error') && error.ideName) {
343
+ throw error;
344
+ }
345
+ throw new CommandInstallationError(this.displayName, entry.name, error.message, {
346
+ sourcePath,
347
+ targetPath,
348
+ });
349
+ }
236
350
  } else if (entry.isDirectory()) {
237
351
  // Recursively process subdirectory
238
352
  const subResult = await this.installCommandsRecursive(
@@ -26,68 +26,31 @@ class ClaudeCodeSetup extends BaseIdeSetup {
26
26
  * @param {Object} options - Setup options
27
27
  */
28
28
  async setup(projectDir, agileflowDir, options = {}) {
29
- console.log(chalk.hex('#e8683a')(` Setting up ${this.displayName}...`));
29
+ // Use standard setup for commands and agents
30
+ const result = await this.setupStandard(projectDir, agileflowDir, {
31
+ targetSubdir: this.commandsDir,
32
+ agileflowFolder: 'agileflow',
33
+ });
30
34
 
31
- // Clean up old installation first
32
- await this.cleanup(projectDir);
33
-
34
- // Create .claude/commands/agileflow directory
35
- const claudeDir = path.join(projectDir, this.configDir);
36
- const commandsDir = path.join(claudeDir, this.commandsDir);
37
- const agileflowCommandsDir = path.join(commandsDir, 'agileflow');
38
-
39
- await this.ensureDir(agileflowCommandsDir);
40
-
41
- // Recursively install all commands (including subdirectories like agents/, session/)
42
- const commandsSource = path.join(agileflowDir, 'commands');
43
- const commandResult = await this.installCommandsRecursive(
44
- commandsSource,
45
- agileflowCommandsDir,
46
- agileflowDir,
47
- true // Inject dynamic content for top-level commands
48
- );
49
-
50
- // Also install agents as slash commands (.claude/commands/agileflow/agents/)
35
+ const { ideDir, agileflowTargetDir } = result;
51
36
  const agentsSource = path.join(agileflowDir, 'agents');
52
- const agentsTargetDir = path.join(agileflowCommandsDir, 'agents');
53
- const agentResult = await this.installCommandsRecursive(
54
- agentsSource,
55
- agentsTargetDir,
56
- agileflowDir,
57
- false // No dynamic content for agents
58
- );
59
-
60
- // ALSO install agents as spawnable subagents (.claude/agents/agileflow/)
37
+
38
+ // Claude Code specific: Install agents as spawnable subagents (.claude/agents/agileflow/)
61
39
  // This allows Task tool to spawn them with subagent_type: "agileflow-ui"
62
- const spawnableAgentsDir = path.join(claudeDir, 'agents', 'agileflow');
40
+ const spawnableAgentsDir = path.join(ideDir, 'agents', 'agileflow');
63
41
  await this.installCommandsRecursive(agentsSource, spawnableAgentsDir, agileflowDir, false);
64
42
  console.log(chalk.dim(` - Spawnable agents: .claude/agents/agileflow/`));
65
43
 
66
- // Create skills directory for user-generated skills (.claude/skills/)
44
+ // Claude Code specific: Create skills directory for user-generated skills
67
45
  // AgileFlow no longer ships static skills - users generate them via /agileflow:skill:create
68
- const skillsTargetDir = path.join(claudeDir, 'skills');
46
+ const skillsTargetDir = path.join(ideDir, 'skills');
69
47
  await this.ensureDir(skillsTargetDir);
70
48
  console.log(chalk.dim(` - Skills directory: .claude/skills/ (for user-generated skills)`));
71
49
 
72
- // Setup damage control hooks
73
- await this.setupDamageControl(projectDir, agileflowDir, claudeDir, options);
74
-
75
- const totalCommands = commandResult.commands + agentResult.commands;
76
- const totalSubdirs =
77
- commandResult.subdirs + (agentResult.commands > 0 ? 1 : 0) + agentResult.subdirs;
78
-
79
- console.log(chalk.green(` ✓ ${this.displayName} configured:`));
80
- console.log(chalk.dim(` - ${totalCommands} commands installed`));
81
- if (totalSubdirs > 0) {
82
- console.log(chalk.dim(` - ${totalSubdirs} subdirectories`));
83
- }
84
- console.log(chalk.dim(` - Path: ${path.relative(projectDir, agileflowCommandsDir)}`));
50
+ // Claude Code specific: Setup damage control hooks
51
+ await this.setupDamageControl(projectDir, agileflowDir, ideDir, options);
85
52
 
86
- return {
87
- success: true,
88
- commands: totalCommands,
89
- subdirs: totalSubdirs,
90
- };
53
+ return result;
91
54
  }
92
55
 
93
56
  /**
@@ -27,45 +27,10 @@ class CursorSetup extends BaseIdeSetup {
27
27
  * @param {Object} options - Setup options
28
28
  */
29
29
  async setup(projectDir, agileflowDir, options = {}) {
30
- console.log(chalk.hex('#e8683a')(` Setting up ${this.displayName}...`));
31
-
32
- // Clean up old installation first
33
- await this.cleanup(projectDir);
34
-
35
- // Create .cursor/commands/AgileFlow directory
36
- const cursorDir = path.join(projectDir, this.configDir);
37
- const commandsDir = path.join(cursorDir, this.commandsDir);
38
- const agileflowCommandsDir = path.join(commandsDir, 'AgileFlow');
39
-
40
- // Install commands using shared recursive method
41
- const commandsSource = path.join(agileflowDir, 'commands');
42
- const commandResult = await this.installCommandsRecursive(
43
- commandsSource,
44
- agileflowCommandsDir,
45
- agileflowDir,
46
- true // Inject dynamic content
47
- );
48
-
49
- // Install agents as subdirectory
50
- const agentsSource = path.join(agileflowDir, 'agents');
51
- const agentsTargetDir = path.join(agileflowCommandsDir, 'agents');
52
- const agentResult = await this.installCommandsRecursive(
53
- agentsSource,
54
- agentsTargetDir,
55
- agileflowDir,
56
- false // No dynamic content for agents
57
- );
58
-
59
- console.log(chalk.green(` ✓ ${this.displayName} configured:`));
60
- console.log(chalk.dim(` - ${commandResult.commands} commands installed`));
61
- console.log(chalk.dim(` - ${agentResult.commands} agents installed`));
62
- console.log(chalk.dim(` - Path: ${path.relative(projectDir, agileflowCommandsDir)}`));
63
-
64
- return {
65
- success: true,
66
- commands: commandResult.commands,
67
- agents: agentResult.commands,
68
- };
30
+ return this.setupStandard(projectDir, agileflowDir, {
31
+ targetSubdir: this.commandsDir,
32
+ agileflowFolder: 'AgileFlow',
33
+ });
69
34
  }
70
35
 
71
36
  /**
@@ -27,45 +27,12 @@ class WindsurfSetup extends BaseIdeSetup {
27
27
  * @param {Object} options - Setup options
28
28
  */
29
29
  async setup(projectDir, agileflowDir, options = {}) {
30
- console.log(chalk.hex('#e8683a')(` Setting up ${this.displayName}...`));
31
-
32
- // Clean up old installation first
33
- await this.cleanup(projectDir);
34
-
35
- // Create .windsurf/workflows/agileflow directory
36
- const windsurfDir = path.join(projectDir, this.configDir);
37
- const workflowsDir = path.join(windsurfDir, this.workflowsDir);
38
- const agileflowWorkflowsDir = path.join(workflowsDir, 'agileflow');
39
-
40
- // Install commands using shared recursive method
41
- const commandsSource = path.join(agileflowDir, 'commands');
42
- const commandResult = await this.installCommandsRecursive(
43
- commandsSource,
44
- agileflowWorkflowsDir,
45
- agileflowDir,
46
- true // Inject dynamic content
47
- );
48
-
49
- // Install agents as subdirectory
50
- const agentsSource = path.join(agileflowDir, 'agents');
51
- const agentsTargetDir = path.join(agileflowWorkflowsDir, 'agents');
52
- const agentResult = await this.installCommandsRecursive(
53
- agentsSource,
54
- agentsTargetDir,
55
- agileflowDir,
56
- false // No dynamic content for agents
57
- );
58
-
59
- console.log(chalk.green(` ✓ ${this.displayName} configured:`));
60
- console.log(chalk.dim(` - ${commandResult.commands} workflows installed`));
61
- console.log(chalk.dim(` - ${agentResult.commands} agent workflows installed`));
62
- console.log(chalk.dim(` - Path: ${path.relative(projectDir, agileflowWorkflowsDir)}`));
63
-
64
- return {
65
- success: true,
66
- commands: commandResult.commands,
67
- agents: agentResult.commands,
68
- };
30
+ return this.setupStandard(projectDir, agileflowDir, {
31
+ targetSubdir: this.workflowsDir,
32
+ agileflowFolder: 'agileflow',
33
+ commandLabel: 'workflows',
34
+ agentLabel: 'agent workflows',
35
+ });
69
36
  }
70
37
 
71
38
  /**
@@ -26,6 +26,7 @@ const path = require('path');
26
26
 
27
27
  // Use shared modules
28
28
  const { parseFrontmatter, normalizeTools } = require('../../../scripts/lib/frontmatter-parser');
29
+ const { validatePath } = require('../../../lib/validate');
29
30
  const {
30
31
  countCommands,
31
32
  countAgents,
@@ -37,6 +38,18 @@ const {
37
38
  // List Generation Functions
38
39
  // =============================================================================
39
40
 
41
+ /**
42
+ * Validate that a file path is within the expected directory.
43
+ * Prevents reading files outside the expected scope.
44
+ * @param {string} filePath - File path to validate
45
+ * @param {string} baseDir - Expected base directory
46
+ * @returns {boolean} True if path is safe
47
+ */
48
+ function isPathSafe(filePath, baseDir) {
49
+ const result = validatePath(filePath, baseDir, { allowSymlinks: true });
50
+ return result.ok;
51
+ }
52
+
40
53
  /**
41
54
  * Scan agents directory and generate formatted agent list
42
55
  * @param {string} agentsDir - Path to agents directory
@@ -50,6 +63,12 @@ function generateAgentList(agentsDir) {
50
63
 
51
64
  for (const file of files) {
52
65
  const filePath = path.join(agentsDir, file);
66
+
67
+ // Validate path before reading to prevent traversal via symlinks or malicious names
68
+ if (!isPathSafe(filePath, agentsDir)) {
69
+ continue;
70
+ }
71
+
53
72
  const content = fs.readFileSync(filePath, 'utf8');
54
73
  const frontmatter = parseFrontmatter(content);
55
74
 
@@ -94,6 +113,12 @@ function generateCommandList(commandsDir) {
94
113
  const mainFiles = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
95
114
  for (const file of mainFiles) {
96
115
  const filePath = path.join(commandsDir, file);
116
+
117
+ // Validate path before reading
118
+ if (!isPathSafe(filePath, commandsDir)) {
119
+ continue;
120
+ }
121
+
97
122
  const content = fs.readFileSync(filePath, 'utf8');
98
123
  const frontmatter = parseFrontmatter(content);
99
124
  const cmdName = path.basename(file, '.md');
@@ -114,10 +139,22 @@ function generateCommandList(commandsDir) {
114
139
  for (const entry of entries) {
115
140
  if (entry.isDirectory()) {
116
141
  const subDir = path.join(commandsDir, entry.name);
142
+
143
+ // Validate subdirectory path
144
+ if (!isPathSafe(subDir, commandsDir)) {
145
+ continue;
146
+ }
147
+
117
148
  const subFiles = fs.readdirSync(subDir).filter(f => f.endsWith('.md'));
118
149
 
119
150
  for (const file of subFiles) {
120
151
  const filePath = path.join(subDir, file);
152
+
153
+ // Validate file path within subdirectory
154
+ if (!isPathSafe(filePath, commandsDir)) {
155
+ continue;
156
+ }
157
+
121
158
  const content = fs.readFileSync(filePath, 'utf8');
122
159
  const frontmatter = parseFrontmatter(content);
123
160
  const cmdName = `${entry.name}:${path.basename(file, '.md')}`;