claude-autopm 1.29.0 → 1.30.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/README.md +29 -1
- package/autopm/.claude/scripts/pm/prd-new.js +33 -6
- package/autopm/.claude/scripts/pm/template-list.js +25 -3
- package/autopm/.claude/scripts/pm/template-new.js +25 -3
- package/autopm/lib/README-FILTER-SEARCH.md +285 -0
- package/autopm/lib/analytics-engine.js +689 -0
- package/autopm/lib/batch-processor-integration.js +366 -0
- package/autopm/lib/batch-processor.js +278 -0
- package/autopm/lib/burndown-chart.js +415 -0
- package/autopm/lib/conflict-history.js +316 -0
- package/autopm/lib/conflict-resolver.js +330 -0
- package/autopm/lib/dependency-analyzer.js +466 -0
- package/autopm/lib/filter-engine.js +414 -0
- package/autopm/lib/guide/interactive-guide.js +756 -0
- package/autopm/lib/guide/manager.js +663 -0
- package/autopm/lib/query-parser.js +322 -0
- package/autopm/lib/template-engine.js +347 -0
- package/autopm/lib/visual-diff.js +297 -0
- package/install/install.js +2 -1
- package/lib/ai-providers/base-provider.js +110 -0
- package/lib/conflict-history.js +316 -0
- package/lib/conflict-resolver.js +330 -0
- package/lib/visual-diff.js +297 -0
- package/package.json +1 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueryParser - Parse command-line filter arguments into structured queries
|
|
3
|
+
*
|
|
4
|
+
* Converts CLI-style filter arguments into structured query objects for
|
|
5
|
+
* filtering PRDs, Epics, and Tasks.
|
|
6
|
+
*
|
|
7
|
+
* @example Basic Usage
|
|
8
|
+
* ```javascript
|
|
9
|
+
* const QueryParser = require('./lib/query-parser');
|
|
10
|
+
* const parser = new QueryParser();
|
|
11
|
+
*
|
|
12
|
+
* // Parse CLI arguments
|
|
13
|
+
* const query = parser.parse(['--status', 'active', '--priority', 'high']);
|
|
14
|
+
* // Returns: { status: 'active', priority: 'high' }
|
|
15
|
+
*
|
|
16
|
+
* // Validate query
|
|
17
|
+
* const validation = parser.validate(query);
|
|
18
|
+
* // Returns: { valid: true, errors: [] }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @example Supported Filters
|
|
22
|
+
* ```javascript
|
|
23
|
+
* // Status filter
|
|
24
|
+
* parser.parse(['--status', 'active']);
|
|
25
|
+
*
|
|
26
|
+
* // Priority filter
|
|
27
|
+
* parser.parse(['--priority', 'P0']);
|
|
28
|
+
*
|
|
29
|
+
* // Date range filter
|
|
30
|
+
* parser.parse(['--created-after', '2025-01-01', '--created-before', '2025-12-31']);
|
|
31
|
+
*
|
|
32
|
+
* // Full-text search
|
|
33
|
+
* parser.parse(['--search', 'authentication API']);
|
|
34
|
+
*
|
|
35
|
+
* // Combined filters
|
|
36
|
+
* parser.parse([
|
|
37
|
+
* '--status', 'active',
|
|
38
|
+
* '--priority', 'high',
|
|
39
|
+
* '--created-after', '2025-01-01',
|
|
40
|
+
* '--search', 'OAuth2'
|
|
41
|
+
* ]);
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @module QueryParser
|
|
45
|
+
* @version 1.0.0
|
|
46
|
+
* @since v1.28.0
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
class QueryParser {
|
|
50
|
+
constructor() {
|
|
51
|
+
/**
|
|
52
|
+
* Supported filter names
|
|
53
|
+
* @type {string[]}
|
|
54
|
+
*/
|
|
55
|
+
this.supportedFilters = [
|
|
56
|
+
'status',
|
|
57
|
+
'priority',
|
|
58
|
+
'epic',
|
|
59
|
+
'author',
|
|
60
|
+
'assignee',
|
|
61
|
+
'created-after',
|
|
62
|
+
'created-before',
|
|
63
|
+
'updated-after',
|
|
64
|
+
'updated-before',
|
|
65
|
+
'search'
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Valid status values
|
|
70
|
+
* @type {string[]}
|
|
71
|
+
*/
|
|
72
|
+
this.validStatuses = [
|
|
73
|
+
'draft',
|
|
74
|
+
'active',
|
|
75
|
+
'in_progress',
|
|
76
|
+
'completed',
|
|
77
|
+
'blocked',
|
|
78
|
+
'archived'
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Valid priority values
|
|
83
|
+
* @type {string[]}
|
|
84
|
+
*/
|
|
85
|
+
this.validPriorities = [
|
|
86
|
+
'P0', 'P1', 'P2', 'P3',
|
|
87
|
+
'p0', 'p1', 'p2', 'p3',
|
|
88
|
+
'high', 'medium', 'low',
|
|
89
|
+
'High', 'Medium', 'Low',
|
|
90
|
+
'HIGH', 'MEDIUM', 'LOW'
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Date filter fields
|
|
95
|
+
* @type {string[]}
|
|
96
|
+
*/
|
|
97
|
+
this.dateFilters = [
|
|
98
|
+
'created-after',
|
|
99
|
+
'created-before',
|
|
100
|
+
'updated-after',
|
|
101
|
+
'updated-before'
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse command-line filter arguments into structured query
|
|
107
|
+
*
|
|
108
|
+
* @param {string[]} args - Command-line arguments (e.g., ['--status', 'active'])
|
|
109
|
+
* @returns {Object} - Parsed query object
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* const query = parser.parse(['--status', 'active', '--priority', 'high']);
|
|
113
|
+
* // Returns: { status: 'active', priority: 'high' }
|
|
114
|
+
*/
|
|
115
|
+
parse(args) {
|
|
116
|
+
const query = {};
|
|
117
|
+
|
|
118
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
119
|
+
return query;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < args.length; i++) {
|
|
123
|
+
const arg = args[i];
|
|
124
|
+
|
|
125
|
+
// Check if this is a filter flag (starts with --)
|
|
126
|
+
if (!arg.startsWith('--')) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Extract filter name (remove -- prefix)
|
|
131
|
+
const filterName = arg.substring(2);
|
|
132
|
+
|
|
133
|
+
// Check if this is a supported filter
|
|
134
|
+
if (!this.supportedFilters.includes(filterName)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Get the next argument as the value
|
|
139
|
+
const value = args[i + 1];
|
|
140
|
+
|
|
141
|
+
// Skip if no value or value is another flag
|
|
142
|
+
if (!value || value.startsWith('--')) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Trim whitespace
|
|
147
|
+
const trimmedValue = value.trim();
|
|
148
|
+
|
|
149
|
+
// Skip empty values
|
|
150
|
+
if (trimmedValue === '') {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Add to query
|
|
155
|
+
query[filterName] = trimmedValue;
|
|
156
|
+
|
|
157
|
+
// Skip the next argument (we just consumed it as a value)
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return query;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Validate query object
|
|
166
|
+
*
|
|
167
|
+
* Checks for valid date formats and other constraints.
|
|
168
|
+
*
|
|
169
|
+
* @param {Object} query - Query object to validate
|
|
170
|
+
* @returns {Object} - { valid: boolean, errors: string[] }
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* const result = parser.validate({ status: 'active', 'created-after': '2025-01-01' });
|
|
174
|
+
* // Returns: { valid: true, errors: [] }
|
|
175
|
+
*
|
|
176
|
+
* const result2 = parser.validate({ 'created-after': 'invalid-date' });
|
|
177
|
+
* // Returns: { valid: false, errors: ['Invalid date format...'] }
|
|
178
|
+
*/
|
|
179
|
+
validate(query) {
|
|
180
|
+
const errors = [];
|
|
181
|
+
|
|
182
|
+
if (!query || typeof query !== 'object') {
|
|
183
|
+
return { valid: true, errors: [] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Validate date filters
|
|
187
|
+
for (const dateFilter of this.dateFilters) {
|
|
188
|
+
if (query[dateFilter]) {
|
|
189
|
+
const value = query[dateFilter];
|
|
190
|
+
|
|
191
|
+
// Check YYYY-MM-DD format
|
|
192
|
+
if (!this.isValidDateFormat(value)) {
|
|
193
|
+
errors.push(`Invalid date format for ${dateFilter}: ${value} (expected YYYY-MM-DD)`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
valid: errors.length === 0,
|
|
200
|
+
errors
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check if a string is a valid date in YYYY-MM-DD format
|
|
206
|
+
*
|
|
207
|
+
* @param {string} dateString - Date string to validate
|
|
208
|
+
* @returns {boolean} - True if valid
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
isValidDateFormat(dateString) {
|
|
212
|
+
// Check format: YYYY-MM-DD
|
|
213
|
+
const regex = /^\d{4}-\d{2}-\d{2}$/;
|
|
214
|
+
if (!regex.test(dateString)) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Parse and validate actual date
|
|
219
|
+
const parts = dateString.split('-');
|
|
220
|
+
const year = parseInt(parts[0], 10);
|
|
221
|
+
const month = parseInt(parts[1], 10);
|
|
222
|
+
const day = parseInt(parts[2], 10);
|
|
223
|
+
|
|
224
|
+
// Check ranges
|
|
225
|
+
if (month < 1 || month > 12) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (day < 1 || day > 31) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Create date and verify it matches input
|
|
234
|
+
// (this catches invalid dates like 2025-02-31)
|
|
235
|
+
const date = new Date(year, month - 1, day);
|
|
236
|
+
return (
|
|
237
|
+
date.getFullYear() === year &&
|
|
238
|
+
date.getMonth() === month - 1 &&
|
|
239
|
+
date.getDate() === day
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get list of supported filters
|
|
245
|
+
*
|
|
246
|
+
* @returns {string[]} - Array of supported filter names
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* const filters = parser.getSupportedFilters();
|
|
250
|
+
* // Returns: ['status', 'priority', 'epic', ...]
|
|
251
|
+
*/
|
|
252
|
+
getSupportedFilters() {
|
|
253
|
+
return [...this.supportedFilters];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get help text for filters
|
|
258
|
+
*
|
|
259
|
+
* Returns formatted help text describing all available filters
|
|
260
|
+
* with examples.
|
|
261
|
+
*
|
|
262
|
+
* @returns {string} - Formatted help text
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* console.log(parser.getFilterHelp());
|
|
266
|
+
* // Outputs:
|
|
267
|
+
* // Available Filters:
|
|
268
|
+
* // --status <value> Filter by status (draft, active, ...)
|
|
269
|
+
* // --priority <value> Filter by priority (P0, P1, high, ...)
|
|
270
|
+
* // ...
|
|
271
|
+
*/
|
|
272
|
+
getFilterHelp() {
|
|
273
|
+
return `
|
|
274
|
+
Available Filters:
|
|
275
|
+
|
|
276
|
+
--status <value> Filter by status
|
|
277
|
+
Values: draft, active, in_progress, completed, blocked, archived
|
|
278
|
+
Example: --status active
|
|
279
|
+
|
|
280
|
+
--priority <value> Filter by priority
|
|
281
|
+
Values: P0, P1, P2, P3, high, medium, low
|
|
282
|
+
Example: --priority high
|
|
283
|
+
|
|
284
|
+
--epic <id> Filter by epic ID
|
|
285
|
+
Example: --epic epic-001
|
|
286
|
+
|
|
287
|
+
--author <name> Filter by author name
|
|
288
|
+
Example: --author john
|
|
289
|
+
|
|
290
|
+
--assignee <name> Filter by assignee name
|
|
291
|
+
Example: --assignee jane
|
|
292
|
+
|
|
293
|
+
--created-after <date> Created after date (YYYY-MM-DD)
|
|
294
|
+
Example: --created-after 2025-01-01
|
|
295
|
+
|
|
296
|
+
--created-before <date> Created before date (YYYY-MM-DD)
|
|
297
|
+
Example: --created-before 2025-12-31
|
|
298
|
+
|
|
299
|
+
--updated-after <date> Updated after date (YYYY-MM-DD)
|
|
300
|
+
Example: --updated-after 2025-06-01
|
|
301
|
+
|
|
302
|
+
--updated-before <date> Updated before date (YYYY-MM-DD)
|
|
303
|
+
Example: --updated-before 2025-06-30
|
|
304
|
+
|
|
305
|
+
--search <text> Full-text search in content and frontmatter
|
|
306
|
+
Example: --search "authentication API"
|
|
307
|
+
|
|
308
|
+
Examples:
|
|
309
|
+
|
|
310
|
+
# Filter by status and priority
|
|
311
|
+
--status active --priority high
|
|
312
|
+
|
|
313
|
+
# Filter by date range
|
|
314
|
+
--created-after 2025-01-01 --created-before 2025-12-31
|
|
315
|
+
|
|
316
|
+
# Combine filters
|
|
317
|
+
--status active --priority P0 --epic epic-001 --search "OAuth2"
|
|
318
|
+
`.trim();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = QueryParser;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Engine - Pure Node.js Implementation
|
|
3
|
+
*
|
|
4
|
+
* NO external dependencies - uses only built-in Node.js modules
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Variable substitution: {{variable}}
|
|
8
|
+
* - Conditionals: {{#if variable}}...{{/if}}
|
|
9
|
+
* - Loops: {{#each items}}...{{/each}}
|
|
10
|
+
* - Auto-generated variables: id, timestamp, author, date
|
|
11
|
+
* - Template discovery: user custom overrides built-in
|
|
12
|
+
*
|
|
13
|
+
* @example Basic Usage
|
|
14
|
+
* ```javascript
|
|
15
|
+
* const TemplateEngine = require('./lib/template-engine');
|
|
16
|
+
* const engine = new TemplateEngine();
|
|
17
|
+
*
|
|
18
|
+
* // Find template
|
|
19
|
+
* const templatePath = engine.findTemplate('prds', 'api-feature');
|
|
20
|
+
*
|
|
21
|
+
* // Render with variables
|
|
22
|
+
* const rendered = engine.renderFile(templatePath, {
|
|
23
|
+
* title: 'User Authentication API',
|
|
24
|
+
* priority: 'P0',
|
|
25
|
+
* problem: 'Users cannot login securely'
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @example Advanced Features
|
|
30
|
+
* ```javascript
|
|
31
|
+
* // Template with conditionals and loops
|
|
32
|
+
* const template = `
|
|
33
|
+
* # {{title}}
|
|
34
|
+
*
|
|
35
|
+
* {{#if description}}
|
|
36
|
+
* ## Description
|
|
37
|
+
* {{description}}
|
|
38
|
+
* {{/if}}
|
|
39
|
+
*
|
|
40
|
+
* ## Features
|
|
41
|
+
* {{#each features}}
|
|
42
|
+
* - {{this}}
|
|
43
|
+
* {{/each}}
|
|
44
|
+
* `;
|
|
45
|
+
*
|
|
46
|
+
* const result = engine.render(template, {
|
|
47
|
+
* title: 'My Feature',
|
|
48
|
+
* description: 'Feature description',
|
|
49
|
+
* features: ['Auth', 'API', 'UI']
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @module TemplateEngine
|
|
54
|
+
* @version 1.0.0
|
|
55
|
+
* @since v1.28.0
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const fs = require('fs');
|
|
59
|
+
const path = require('path');
|
|
60
|
+
|
|
61
|
+
class TemplateEngine {
|
|
62
|
+
constructor(builtInDir, userDir) {
|
|
63
|
+
// For testing, allow custom built-in directory and user directory
|
|
64
|
+
// In production, use autopm/.claude/templates and .claude/templates
|
|
65
|
+
this.builtInDir = builtInDir || path.join(__dirname, '..', 'autopm', '.claude', 'templates');
|
|
66
|
+
this.userDir = userDir || path.join('.claude', 'templates');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Find template by name
|
|
71
|
+
* Priority: user templates > built-in templates
|
|
72
|
+
*
|
|
73
|
+
* @param {string} type - Template type (prds/epics/tasks)
|
|
74
|
+
* @param {string} name - Template name (without .md extension)
|
|
75
|
+
* @returns {string|null} - Path to template or null if not found
|
|
76
|
+
*/
|
|
77
|
+
findTemplate(type, name) {
|
|
78
|
+
const userPath = path.resolve(this.userDir, type, `${name}.md`);
|
|
79
|
+
const builtInPath = path.resolve(this.builtInDir, type, `${name}.md`);
|
|
80
|
+
|
|
81
|
+
if (fs.existsSync(userPath)) return userPath;
|
|
82
|
+
if (fs.existsSync(builtInPath)) return builtInPath;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* List all available templates of a given type
|
|
88
|
+
*
|
|
89
|
+
* @param {string} type - Template type (prds/epics/tasks)
|
|
90
|
+
* @returns {Array<{name: string, custom: boolean}>}
|
|
91
|
+
*/
|
|
92
|
+
listTemplates(type) {
|
|
93
|
+
const templates = [];
|
|
94
|
+
|
|
95
|
+
// Built-in templates
|
|
96
|
+
const builtInPath = path.join(this.builtInDir, type);
|
|
97
|
+
if (fs.existsSync(builtInPath)) {
|
|
98
|
+
const files = fs.readdirSync(builtInPath)
|
|
99
|
+
.filter(f => f.endsWith('.md'))
|
|
100
|
+
.map(f => ({ name: f.replace('.md', ''), custom: false }));
|
|
101
|
+
templates.push(...files);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// User custom templates
|
|
105
|
+
const userPath = path.join(this.userDir, type);
|
|
106
|
+
if (fs.existsSync(userPath)) {
|
|
107
|
+
const files = fs.readdirSync(userPath)
|
|
108
|
+
.filter(f => f.endsWith('.md'))
|
|
109
|
+
.map(f => ({ name: f.replace('.md', ''), custom: true }));
|
|
110
|
+
templates.push(...files);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return templates;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Generate auto variables
|
|
118
|
+
*
|
|
119
|
+
* @returns {Object} - Auto-generated variables
|
|
120
|
+
*/
|
|
121
|
+
generateAutoVariables() {
|
|
122
|
+
const now = new Date();
|
|
123
|
+
return {
|
|
124
|
+
id: '', // Will be set by generateId()
|
|
125
|
+
timestamp: now.toISOString(),
|
|
126
|
+
date: now.toISOString().split('T')[0],
|
|
127
|
+
author: process.env.USER || process.env.USERNAME || 'unknown'
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Generate sequential ID
|
|
133
|
+
*
|
|
134
|
+
* @param {string} prefix - ID prefix (prd/epic/task)
|
|
135
|
+
* @param {string} directory - Directory to scan for existing IDs
|
|
136
|
+
* @returns {string} - Next sequential ID
|
|
137
|
+
*/
|
|
138
|
+
generateId(prefix, directory) {
|
|
139
|
+
if (!fs.existsSync(directory)) {
|
|
140
|
+
return `${prefix}-001`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const files = fs.readdirSync(directory)
|
|
144
|
+
.filter(f => f.startsWith(`${prefix}-`) && f.endsWith('.md'));
|
|
145
|
+
|
|
146
|
+
if (files.length === 0) {
|
|
147
|
+
return `${prefix}-001`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Extract numbers and find max
|
|
151
|
+
const numbers = files
|
|
152
|
+
.map(f => {
|
|
153
|
+
const match = f.match(new RegExp(`${prefix}-(\\d+)\\.md`));
|
|
154
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
155
|
+
})
|
|
156
|
+
.filter(n => !isNaN(n));
|
|
157
|
+
|
|
158
|
+
const maxNum = Math.max(...numbers);
|
|
159
|
+
const nextNum = maxNum + 1;
|
|
160
|
+
|
|
161
|
+
return `${prefix}-${String(nextNum).padStart(3, '0')}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Render template with variables
|
|
166
|
+
*
|
|
167
|
+
* @param {string} template - Template string
|
|
168
|
+
* @param {Object} variables - Variables to substitute
|
|
169
|
+
* @returns {string} - Rendered template
|
|
170
|
+
*/
|
|
171
|
+
render(template, variables) {
|
|
172
|
+
// Auto-generate variables
|
|
173
|
+
const autoVars = this.generateAutoVariables();
|
|
174
|
+
|
|
175
|
+
// Generate ID if not provided
|
|
176
|
+
if (!variables.id && !autoVars.id) {
|
|
177
|
+
// Try to infer type and directory from variables
|
|
178
|
+
const type = variables.type || 'item';
|
|
179
|
+
const directory = `.claude/${type}s`;
|
|
180
|
+
autoVars.id = this.generateId(type, directory);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const allVars = { ...autoVars, ...variables };
|
|
184
|
+
|
|
185
|
+
let content = template;
|
|
186
|
+
|
|
187
|
+
// Process loops first ({{#each}}...{{/each}})
|
|
188
|
+
content = this.processLoops(content, allVars);
|
|
189
|
+
|
|
190
|
+
// Process conditionals ({{#if}}...{{/if}})
|
|
191
|
+
content = this.processConditionals(content, allVars);
|
|
192
|
+
|
|
193
|
+
// Simple variable substitution ({{variable}})
|
|
194
|
+
for (const [key, value] of Object.entries(allVars)) {
|
|
195
|
+
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
|
196
|
+
content = content.replace(regex, value || '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Replace any remaining {{variables}} that weren't in allVars with empty string
|
|
200
|
+
content = content.replace(/\{\{(\w+)\}\}/g, '');
|
|
201
|
+
|
|
202
|
+
return content;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Render template from file
|
|
207
|
+
*
|
|
208
|
+
* @param {string} templatePath - Path to template file
|
|
209
|
+
* @param {Object} variables - Variables to substitute
|
|
210
|
+
* @returns {string} - Rendered template
|
|
211
|
+
*/
|
|
212
|
+
renderFile(templatePath, variables) {
|
|
213
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
214
|
+
return this.render(template, variables);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Process conditionals {{#if var}}...{{/if}}
|
|
219
|
+
*
|
|
220
|
+
* @param {string} content - Template content
|
|
221
|
+
* @param {Object} vars - Variables
|
|
222
|
+
* @returns {string} - Processed content
|
|
223
|
+
*/
|
|
224
|
+
processConditionals(content, vars) {
|
|
225
|
+
// Match {{#if variable}}...{{/if}} - process from inside out for nested conditionals
|
|
226
|
+
const ifRegex = /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/;
|
|
227
|
+
|
|
228
|
+
let result = content;
|
|
229
|
+
let changed = true;
|
|
230
|
+
let iterations = 0;
|
|
231
|
+
const maxIterations = 100;
|
|
232
|
+
|
|
233
|
+
// Keep processing until no more conditionals found (handles nesting)
|
|
234
|
+
while (changed && iterations < maxIterations) {
|
|
235
|
+
iterations++;
|
|
236
|
+
changed = false;
|
|
237
|
+
|
|
238
|
+
const match = result.match(ifRegex);
|
|
239
|
+
if (match) {
|
|
240
|
+
const [fullMatch, varName, innerContent] = match;
|
|
241
|
+
const varValue = vars[varName];
|
|
242
|
+
|
|
243
|
+
// If variable is truthy, keep inner content; otherwise remove
|
|
244
|
+
const replacement = varValue ? innerContent : '';
|
|
245
|
+
result = result.replace(fullMatch, replacement);
|
|
246
|
+
changed = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Process loops {{#each items}}...{{/each}}
|
|
255
|
+
*
|
|
256
|
+
* @param {string} content - Template content
|
|
257
|
+
* @param {Object} vars - Variables
|
|
258
|
+
* @returns {string} - Processed content
|
|
259
|
+
*/
|
|
260
|
+
processLoops(content, vars) {
|
|
261
|
+
// Match {{#each variable}}...{{/each}}
|
|
262
|
+
// Limit inner content to 10,000 characters to prevent ReDoS
|
|
263
|
+
const eachRegex = /\{\{#each\s+(\w+)\}\}([\s\S]{0,10000}?)\{\{\/each\}\}/g;
|
|
264
|
+
|
|
265
|
+
let result = content;
|
|
266
|
+
let match;
|
|
267
|
+
|
|
268
|
+
// Safety counter
|
|
269
|
+
let iterations = 0;
|
|
270
|
+
const maxIterations = 100;
|
|
271
|
+
|
|
272
|
+
while ((match = eachRegex.exec(content)) !== null && iterations < maxIterations) {
|
|
273
|
+
iterations++;
|
|
274
|
+
const [fullMatch, varName, template] = match;
|
|
275
|
+
const items = vars[varName];
|
|
276
|
+
|
|
277
|
+
if (!Array.isArray(items)) {
|
|
278
|
+
// Not an array, remove the loop
|
|
279
|
+
result = result.replace(fullMatch, '');
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Render each item
|
|
284
|
+
let rendered = '';
|
|
285
|
+
for (const item of items) {
|
|
286
|
+
if (typeof item === 'object' && item !== null) {
|
|
287
|
+
// Object: replace {{property}} with object properties
|
|
288
|
+
let itemRendered = template;
|
|
289
|
+
for (const [key, value] of Object.entries(item)) {
|
|
290
|
+
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
|
291
|
+
itemRendered = itemRendered.replace(regex, value || '');
|
|
292
|
+
}
|
|
293
|
+
rendered += itemRendered;
|
|
294
|
+
} else {
|
|
295
|
+
// Primitive: replace {{this}} with the value
|
|
296
|
+
rendered += template.replace(/\{\{this\}\}/g, item);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
result = result.replace(fullMatch, rendered);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Validate template
|
|
308
|
+
*
|
|
309
|
+
* @param {string} template - Template string
|
|
310
|
+
* @returns {Object} - {valid: boolean, errors: string[]}
|
|
311
|
+
*/
|
|
312
|
+
validate(template) {
|
|
313
|
+
const errors = [];
|
|
314
|
+
|
|
315
|
+
// Check frontmatter
|
|
316
|
+
if (!template.startsWith('---')) {
|
|
317
|
+
errors.push('Missing frontmatter');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Check required variables
|
|
321
|
+
const requiredVars = ['id', 'title', 'type'];
|
|
322
|
+
for (const varName of requiredVars) {
|
|
323
|
+
if (!template.includes(`{{${varName}}}`)) {
|
|
324
|
+
errors.push(`Missing required variable: {{${varName}}}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
valid: errors.length === 0,
|
|
330
|
+
errors
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Ensure template directory exists
|
|
336
|
+
*
|
|
337
|
+
* @param {string} type - Template type (prds/epics/tasks)
|
|
338
|
+
*/
|
|
339
|
+
ensureTemplateDir(type) {
|
|
340
|
+
const dir = path.join(this.userDir, type);
|
|
341
|
+
if (!fs.existsSync(dir)) {
|
|
342
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = TemplateEngine;
|