agileflow 2.87.0 → 2.89.0
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 +358 -0
- package/lib/progress.js +331 -0
- package/lib/validate.js +281 -1
- package/lib/yaml-utils.js +122 -0
- package/package.json +1 -3
- package/scripts/agileflow-welcome.js +19 -33
- package/scripts/damage-control-bash.js +2 -59
- package/scripts/damage-control-edit.js +4 -69
- package/scripts/damage-control-write.js +4 -69
- package/scripts/lib/damage-control-utils.js +153 -0
- package/scripts/obtain-context.js +4 -5
- package/tools/cli/installers/core/installer.js +32 -2
- package/tools/cli/installers/ide/_base-ide.js +143 -19
- package/tools/cli/installers/ide/claude-code.js +14 -51
- package/tools/cli/installers/ide/cursor.js +4 -40
- package/tools/cli/installers/ide/windsurf.js +6 -40
- package/tools/cli/lib/content-injector.js +37 -0
- package/tools/cli/lib/ide-errors.js +233 -0
|
@@ -15,85 +15,20 @@
|
|
|
15
15
|
const {
|
|
16
16
|
findProjectRoot,
|
|
17
17
|
loadPatterns,
|
|
18
|
-
pathMatches,
|
|
19
18
|
outputBlocked,
|
|
20
19
|
runDamageControlHook,
|
|
20
|
+
parsePathPatterns,
|
|
21
|
+
validatePathAgainstPatterns,
|
|
21
22
|
} = require('./lib/damage-control-utils');
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
-
* Parse simplified YAML for path patterns
|
|
25
|
-
*/
|
|
26
|
-
function parseSimpleYAML(content) {
|
|
27
|
-
const config = {
|
|
28
|
-
zeroAccessPaths: [],
|
|
29
|
-
readOnlyPaths: [],
|
|
30
|
-
noDeletePaths: [],
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
let currentSection = null;
|
|
34
|
-
|
|
35
|
-
for (const line of content.split('\n')) {
|
|
36
|
-
const trimmed = line.trim();
|
|
37
|
-
|
|
38
|
-
// Skip empty lines and comments
|
|
39
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
40
|
-
|
|
41
|
-
// Detect section headers
|
|
42
|
-
if (trimmed === 'zeroAccessPaths:') {
|
|
43
|
-
currentSection = 'zeroAccessPaths';
|
|
44
|
-
} else if (trimmed === 'readOnlyPaths:') {
|
|
45
|
-
currentSection = 'readOnlyPaths';
|
|
46
|
-
} else if (trimmed === 'noDeletePaths:') {
|
|
47
|
-
currentSection = 'noDeletePaths';
|
|
48
|
-
} else if (trimmed.endsWith(':') && !trimmed.startsWith('-')) {
|
|
49
|
-
// Other sections we don't care about for path validation
|
|
50
|
-
currentSection = null;
|
|
51
|
-
} else if (trimmed.startsWith('- ') && currentSection && config[currentSection]) {
|
|
52
|
-
// Path entry
|
|
53
|
-
const pathValue = trimmed.slice(2).replace(/^["']|["']$/g, '');
|
|
54
|
-
config[currentSection].push(pathValue);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return config;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Validate file path for write operation
|
|
63
|
-
*/
|
|
64
|
-
function validatePath(filePath, config) {
|
|
65
|
-
// Check zero access paths - completely blocked
|
|
66
|
-
const zeroMatch = pathMatches(filePath, config.zeroAccessPaths || []);
|
|
67
|
-
if (zeroMatch) {
|
|
68
|
-
return {
|
|
69
|
-
action: 'block',
|
|
70
|
-
reason: `Zero-access path: ${zeroMatch}`,
|
|
71
|
-
detail: 'This file is protected and cannot be accessed',
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Check read-only paths - cannot write
|
|
76
|
-
const readOnlyMatch = pathMatches(filePath, config.readOnlyPaths || []);
|
|
77
|
-
if (readOnlyMatch) {
|
|
78
|
-
return {
|
|
79
|
-
action: 'block',
|
|
80
|
-
reason: `Read-only path: ${readOnlyMatch}`,
|
|
81
|
-
detail: 'This file is read-only and cannot be written to',
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Allow by default
|
|
86
|
-
return { action: 'allow' };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
24
|
// Run the hook
|
|
90
25
|
const projectRoot = findProjectRoot();
|
|
91
26
|
const defaultConfig = { zeroAccessPaths: [], readOnlyPaths: [], noDeletePaths: [] };
|
|
92
27
|
|
|
93
28
|
runDamageControlHook({
|
|
94
29
|
getInputValue: input => input.file_path || input.tool_input?.file_path,
|
|
95
|
-
loadConfig: () => loadPatterns(projectRoot,
|
|
96
|
-
validate:
|
|
30
|
+
loadConfig: () => loadPatterns(projectRoot, parsePathPatterns, defaultConfig),
|
|
31
|
+
validate: (filePath, config) => validatePathAgainstPatterns(filePath, config, 'write'),
|
|
97
32
|
onBlock: (result, filePath) => {
|
|
98
33
|
outputBlocked(result.reason, result.detail, `File: ${filePath}`);
|
|
99
34
|
},
|
|
@@ -238,6 +238,155 @@ function runDamageControlHook(options) {
|
|
|
238
238
|
}, STDIN_TIMEOUT_MS);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Parse simplified YAML for damage control patterns
|
|
243
|
+
* Handles both pattern-based sections (with pattern/reason/flags objects)
|
|
244
|
+
* and list-based sections (with simple string arrays)
|
|
245
|
+
*
|
|
246
|
+
* @param {string} content - YAML file content
|
|
247
|
+
* @param {object} sectionConfig - Map of section names to their type ('patterns' or 'list')
|
|
248
|
+
* @returns {object} Parsed configuration
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* // For bash patterns (pattern objects):
|
|
252
|
+
* parseSimpleYAML(content, {
|
|
253
|
+
* bashToolPatterns: 'patterns',
|
|
254
|
+
* askPatterns: 'patterns',
|
|
255
|
+
* agileflowProtections: 'patterns',
|
|
256
|
+
* })
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* // For path lists (string arrays):
|
|
260
|
+
* parseSimpleYAML(content, {
|
|
261
|
+
* zeroAccessPaths: 'list',
|
|
262
|
+
* readOnlyPaths: 'list',
|
|
263
|
+
* noDeletePaths: 'list',
|
|
264
|
+
* })
|
|
265
|
+
*/
|
|
266
|
+
function parseSimpleYAML(content, sectionConfig) {
|
|
267
|
+
// Initialize result with empty arrays for each section
|
|
268
|
+
const config = {};
|
|
269
|
+
for (const section of Object.keys(sectionConfig)) {
|
|
270
|
+
config[section] = [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let currentSection = null;
|
|
274
|
+
let currentPattern = null;
|
|
275
|
+
|
|
276
|
+
for (const line of content.split('\n')) {
|
|
277
|
+
const trimmed = line.trim();
|
|
278
|
+
|
|
279
|
+
// Skip empty lines and comments
|
|
280
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
281
|
+
|
|
282
|
+
// Check if this line is a section header we care about
|
|
283
|
+
const sectionMatch = trimmed.match(/^(\w+):$/);
|
|
284
|
+
if (sectionMatch) {
|
|
285
|
+
const sectionName = sectionMatch[1];
|
|
286
|
+
if (sectionConfig[sectionName]) {
|
|
287
|
+
currentSection = sectionName;
|
|
288
|
+
currentPattern = null;
|
|
289
|
+
} else {
|
|
290
|
+
// Section we don't care about
|
|
291
|
+
currentSection = null;
|
|
292
|
+
currentPattern = null;
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Skip if we're not in a tracked section
|
|
298
|
+
if (!currentSection) continue;
|
|
299
|
+
|
|
300
|
+
const sectionType = sectionConfig[currentSection];
|
|
301
|
+
|
|
302
|
+
if (sectionType === 'patterns') {
|
|
303
|
+
// Pattern-based section (objects with pattern/reason/flags)
|
|
304
|
+
if (trimmed.startsWith('- pattern:')) {
|
|
305
|
+
const patternValue = trimmed
|
|
306
|
+
.replace('- pattern:', '')
|
|
307
|
+
.trim()
|
|
308
|
+
.replace(/^["']|["']$/g, '');
|
|
309
|
+
currentPattern = { pattern: patternValue };
|
|
310
|
+
config[currentSection].push(currentPattern);
|
|
311
|
+
} else if (trimmed.startsWith('reason:') && currentPattern) {
|
|
312
|
+
currentPattern.reason = trimmed
|
|
313
|
+
.replace('reason:', '')
|
|
314
|
+
.trim()
|
|
315
|
+
.replace(/^["']|["']$/g, '');
|
|
316
|
+
} else if (trimmed.startsWith('flags:') && currentPattern) {
|
|
317
|
+
currentPattern.flags = trimmed
|
|
318
|
+
.replace('flags:', '')
|
|
319
|
+
.trim()
|
|
320
|
+
.replace(/^["']|["']$/g, '');
|
|
321
|
+
}
|
|
322
|
+
} else if (sectionType === 'list') {
|
|
323
|
+
// List-based section (simple string arrays)
|
|
324
|
+
if (trimmed.startsWith('- ')) {
|
|
325
|
+
const value = trimmed.slice(2).replace(/^["']|["']$/g, '');
|
|
326
|
+
config[currentSection].push(value);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return config;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Pre-configured parser for bash tool patterns
|
|
336
|
+
*/
|
|
337
|
+
function parseBashPatterns(content) {
|
|
338
|
+
return parseSimpleYAML(content, {
|
|
339
|
+
bashToolPatterns: 'patterns',
|
|
340
|
+
askPatterns: 'patterns',
|
|
341
|
+
agileflowProtections: 'patterns',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Pre-configured parser for path patterns (edit/write)
|
|
347
|
+
*/
|
|
348
|
+
function parsePathPatterns(content) {
|
|
349
|
+
return parseSimpleYAML(content, {
|
|
350
|
+
zeroAccessPaths: 'list',
|
|
351
|
+
readOnlyPaths: 'list',
|
|
352
|
+
noDeletePaths: 'list',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Validate file path against path patterns
|
|
358
|
+
* Used by both edit and write hooks
|
|
359
|
+
*
|
|
360
|
+
* @param {string} filePath - File path to validate
|
|
361
|
+
* @param {object} config - Parsed path patterns config
|
|
362
|
+
* @param {string} operation - Operation type ('edit' or 'write') for error messages
|
|
363
|
+
* @returns {object} Validation result { action, reason?, detail? }
|
|
364
|
+
*/
|
|
365
|
+
function validatePathAgainstPatterns(filePath, config, operation = 'access') {
|
|
366
|
+
// Check zero access paths - completely blocked
|
|
367
|
+
const zeroMatch = pathMatches(filePath, config.zeroAccessPaths || []);
|
|
368
|
+
if (zeroMatch) {
|
|
369
|
+
return {
|
|
370
|
+
action: 'block',
|
|
371
|
+
reason: `Zero-access path: ${zeroMatch}`,
|
|
372
|
+
detail: 'This file is protected and cannot be accessed',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check read-only paths - cannot edit/write
|
|
377
|
+
const readOnlyMatch = pathMatches(filePath, config.readOnlyPaths || []);
|
|
378
|
+
if (readOnlyMatch) {
|
|
379
|
+
return {
|
|
380
|
+
action: 'block',
|
|
381
|
+
reason: `Read-only path: ${readOnlyMatch}`,
|
|
382
|
+
detail: `This file is read-only and cannot be ${operation === 'edit' ? 'edited' : 'written to'}`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Allow by default
|
|
387
|
+
return { action: 'allow' };
|
|
388
|
+
}
|
|
389
|
+
|
|
241
390
|
module.exports = {
|
|
242
391
|
c,
|
|
243
392
|
findProjectRoot,
|
|
@@ -246,6 +395,10 @@ module.exports = {
|
|
|
246
395
|
pathMatches,
|
|
247
396
|
outputBlocked,
|
|
248
397
|
runDamageControlHook,
|
|
398
|
+
parseSimpleYAML,
|
|
399
|
+
parseBashPatterns,
|
|
400
|
+
parsePathPatterns,
|
|
401
|
+
validatePathAgainstPatterns,
|
|
249
402
|
CONFIG_PATHS,
|
|
250
403
|
STDIN_TIMEOUT_MS,
|
|
251
404
|
};
|
|
@@ -20,6 +20,7 @@ const path = require('path');
|
|
|
20
20
|
const { execSync } = require('child_process');
|
|
21
21
|
const { c: C, box } = require('../lib/colors');
|
|
22
22
|
const { isValidCommandName } = require('../lib/validate');
|
|
23
|
+
const { readJSONCached, readFileCached } = require('../lib/file-cache');
|
|
23
24
|
|
|
24
25
|
// Claude Code's Bash tool truncates around 30K chars, but ANSI codes and
|
|
25
26
|
// box-drawing characters (╭╮╰╯─│) are multi-byte UTF-8, so we need buffer.
|
|
@@ -131,11 +132,9 @@ function safeRead(filePath) {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
function safeReadJSON(filePath) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
135
|
+
// Use cached read for common JSON files
|
|
136
|
+
const absPath = path.resolve(filePath);
|
|
137
|
+
return readJSONCached(absPath);
|
|
139
138
|
}
|
|
140
139
|
|
|
141
140
|
function safeLs(dirPath) {
|
|
@@ -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,25 @@ 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(
|
|
199
|
+
this.displayName,
|
|
200
|
+
agileflowPath,
|
|
201
|
+
error.message
|
|
202
|
+
);
|
|
203
|
+
}
|
|
116
204
|
}
|
|
117
205
|
}
|
|
118
206
|
}
|
|
@@ -140,21 +228,27 @@ class BaseIdeSetup {
|
|
|
140
228
|
}
|
|
141
229
|
|
|
142
230
|
/**
|
|
143
|
-
* Write a file
|
|
231
|
+
* Write a file with permission error handling
|
|
144
232
|
* @param {string} filePath - File path
|
|
145
233
|
* @param {string} content - File content
|
|
234
|
+
* @throws {FilePermissionError} If permission denied
|
|
146
235
|
*/
|
|
147
236
|
async writeFile(filePath, content) {
|
|
148
|
-
await
|
|
237
|
+
await withPermissionHandling(this.displayName, filePath, 'write', async () => {
|
|
238
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
239
|
+
});
|
|
149
240
|
}
|
|
150
241
|
|
|
151
242
|
/**
|
|
152
|
-
* Read a file
|
|
243
|
+
* Read a file with permission error handling
|
|
153
244
|
* @param {string} filePath - File path
|
|
154
245
|
* @returns {Promise<string>} File content
|
|
246
|
+
* @throws {FilePermissionError} If permission denied
|
|
155
247
|
*/
|
|
156
248
|
async readFile(filePath) {
|
|
157
|
-
return
|
|
249
|
+
return withPermissionHandling(this.displayName, filePath, 'read', async () => {
|
|
250
|
+
return fs.readFile(filePath, 'utf8');
|
|
251
|
+
});
|
|
158
252
|
}
|
|
159
253
|
|
|
160
254
|
/**
|
|
@@ -202,6 +296,8 @@ class BaseIdeSetup {
|
|
|
202
296
|
* @param {string} agileflowDir - AgileFlow installation directory (for dynamic content)
|
|
203
297
|
* @param {boolean} injectDynamic - Whether to inject dynamic content (only for top-level commands)
|
|
204
298
|
* @returns {Promise<{commands: number, subdirs: number}>} Count of installed items
|
|
299
|
+
* @throws {CommandInstallationError} If command installation fails
|
|
300
|
+
* @throws {FilePermissionError} If permission denied
|
|
205
301
|
*/
|
|
206
302
|
async installCommandsRecursive(sourceDir, targetDir, agileflowDir, injectDynamic = false) {
|
|
207
303
|
let commandCount = 0;
|
|
@@ -211,7 +307,14 @@ class BaseIdeSetup {
|
|
|
211
307
|
return { commands: 0, subdirs: 0 };
|
|
212
308
|
}
|
|
213
309
|
|
|
214
|
-
|
|
310
|
+
try {
|
|
311
|
+
await this.ensureDir(targetDir);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
314
|
+
throw new FilePermissionError(this.displayName, targetDir, 'write');
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
215
318
|
|
|
216
319
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
217
320
|
|
|
@@ -220,19 +323,40 @@ class BaseIdeSetup {
|
|
|
220
323
|
const targetPath = path.join(targetDir, entry.name);
|
|
221
324
|
|
|
222
325
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
223
|
-
|
|
224
|
-
|
|
326
|
+
try {
|
|
327
|
+
// Read and process .md file
|
|
328
|
+
let content = await this.readFile(sourcePath);
|
|
225
329
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
330
|
+
// Inject dynamic content if enabled (for top-level commands)
|
|
331
|
+
if (injectDynamic) {
|
|
332
|
+
try {
|
|
333
|
+
content = this.injectDynamicContent(content, agileflowDir);
|
|
334
|
+
} catch (injectionError) {
|
|
335
|
+
throw new ContentInjectionError(
|
|
336
|
+
this.displayName,
|
|
337
|
+
sourcePath,
|
|
338
|
+
injectionError.message
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
230
342
|
|
|
231
|
-
|
|
232
|
-
|
|
343
|
+
// Replace docs/ references with custom folder name
|
|
344
|
+
content = this.replaceDocsReferences(content);
|
|
233
345
|
|
|
234
|
-
|
|
235
|
-
|
|
346
|
+
await this.writeFile(targetPath, content);
|
|
347
|
+
commandCount++;
|
|
348
|
+
} catch (error) {
|
|
349
|
+
// Re-throw typed errors as-is
|
|
350
|
+
if (error.name && error.name.includes('Error') && error.ideName) {
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
throw new CommandInstallationError(
|
|
354
|
+
this.displayName,
|
|
355
|
+
entry.name,
|
|
356
|
+
error.message,
|
|
357
|
+
{ sourcePath, targetPath }
|
|
358
|
+
);
|
|
359
|
+
}
|
|
236
360
|
} else if (entry.isDirectory()) {
|
|
237
361
|
// Recursively process subdirectory
|
|
238
362
|
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
|
/**
|