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.
- package/CHANGELOG.md +10 -0
- package/lib/file-cache.js +359 -0
- package/lib/progress.js +333 -0
- package/lib/validate.js +280 -1
- package/lib/yaml-utils.js +118 -0
- package/package.json +1 -1
- package/scripts/agileflow-welcome.js +61 -40
- package/scripts/batch-pmap-loop.js +21 -12
- package/scripts/obtain-context.js +7 -8
- package/scripts/precompact-context.sh +14 -0
- package/scripts/session-manager.js +16 -10
- package/scripts/test-session-boundary.js +2 -2
- package/tools/cli/commands/config.js +1 -5
- package/tools/cli/commands/setup.js +1 -5
- package/tools/cli/installers/core/installer.js +32 -2
- package/tools/cli/installers/ide/_base-ide.js +133 -19
- package/tools/cli/installers/ide/claude-code.js +14 -51
- package/tools/cli/installers/ide/cursor.js +4 -39
- package/tools/cli/installers/ide/windsurf.js +6 -39
- package/tools/cli/lib/content-injector.js +37 -0
- package/tools/cli/lib/ide-errors.js +233 -0
|
@@ -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 =
|
|
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
|
-
|
|
755
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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 =
|
|
67
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
322
|
+
try {
|
|
323
|
+
// Read and process .md file
|
|
324
|
+
let content = await this.readFile(sourcePath);
|
|
225
325
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
335
|
+
// Replace docs/ references with custom folder name
|
|
336
|
+
content = this.replaceDocsReferences(content);
|
|
233
337
|
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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')}`;
|