@fermindi/pwn-cli 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/cli/batch.js +333 -0
  4. package/cli/codespaces.js +303 -0
  5. package/cli/index.js +91 -0
  6. package/cli/inject.js +53 -0
  7. package/cli/knowledge.js +531 -0
  8. package/cli/notify.js +135 -0
  9. package/cli/patterns.js +665 -0
  10. package/cli/status.js +91 -0
  11. package/cli/validate.js +61 -0
  12. package/package.json +70 -0
  13. package/src/core/inject.js +128 -0
  14. package/src/core/state.js +91 -0
  15. package/src/core/validate.js +202 -0
  16. package/src/core/workspace.js +176 -0
  17. package/src/index.js +20 -0
  18. package/src/knowledge/gc.js +308 -0
  19. package/src/knowledge/lifecycle.js +401 -0
  20. package/src/knowledge/promote.js +364 -0
  21. package/src/knowledge/references.js +342 -0
  22. package/src/patterns/matcher.js +218 -0
  23. package/src/patterns/registry.js +375 -0
  24. package/src/patterns/triggers.js +423 -0
  25. package/src/services/batch-service.js +849 -0
  26. package/src/services/notification-service.js +342 -0
  27. package/templates/codespaces/devcontainer.json +52 -0
  28. package/templates/codespaces/setup.sh +70 -0
  29. package/templates/workspace/.ai/README.md +164 -0
  30. package/templates/workspace/.ai/agents/README.md +204 -0
  31. package/templates/workspace/.ai/agents/claude.md +625 -0
  32. package/templates/workspace/.ai/config/.gitkeep +0 -0
  33. package/templates/workspace/.ai/config/README.md +79 -0
  34. package/templates/workspace/.ai/config/notifications.template.json +20 -0
  35. package/templates/workspace/.ai/memory/deadends.md +79 -0
  36. package/templates/workspace/.ai/memory/decisions.md +58 -0
  37. package/templates/workspace/.ai/memory/patterns.md +65 -0
  38. package/templates/workspace/.ai/patterns/backend/README.md +126 -0
  39. package/templates/workspace/.ai/patterns/frontend/README.md +103 -0
  40. package/templates/workspace/.ai/patterns/index.md +256 -0
  41. package/templates/workspace/.ai/patterns/triggers.json +1087 -0
  42. package/templates/workspace/.ai/patterns/universal/README.md +141 -0
  43. package/templates/workspace/.ai/state.template.json +8 -0
  44. package/templates/workspace/.ai/tasks/active.md +77 -0
  45. package/templates/workspace/.ai/tasks/backlog.md +95 -0
  46. package/templates/workspace/.ai/workflows/batch-task.md +356 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Pattern Matcher - Glob and regex matching utilities
3
+ *
4
+ * Provides minimatch-like glob matching without external dependencies.
5
+ * Supports common glob patterns used in trigger evaluation.
6
+ */
7
+
8
+ /**
9
+ * Convert glob pattern to regex
10
+ * @param {string} pattern - Glob pattern
11
+ * @returns {RegExp}
12
+ */
13
+ export function globToRegex(pattern) {
14
+ // Escape special regex characters except glob wildcards
15
+ let regex = pattern
16
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
17
+ // ** matches any path segments
18
+ .replace(/\*\*/g, '{{GLOBSTAR}}')
19
+ // * matches any characters except path separator
20
+ .replace(/\*/g, '[^/\\\\]*')
21
+ // ? matches single character
22
+ .replace(/\?/g, '[^/\\\\]')
23
+ // Restore globstar
24
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*');
25
+
26
+ // Handle brace expansion {a,b,c}
27
+ if (regex.includes('\\{') && regex.includes('\\}')) {
28
+ regex = regex.replace(/\\\{([^}]+)\\\}/g, (_, options) => {
29
+ const parts = options.split(',').map(p => p.trim());
30
+ return `(?:${parts.join('|')})`;
31
+ });
32
+ }
33
+
34
+ return new RegExp(`^${regex}$`, 'i');
35
+ }
36
+
37
+ /**
38
+ * Test if a string matches a glob pattern
39
+ * @param {string} str - String to test
40
+ * @param {string} pattern - Glob pattern
41
+ * @param {Object} [options] - Match options
42
+ * @param {boolean} [options.dot=false] - Match dotfiles
43
+ * @param {boolean} [options.nocase=true] - Case insensitive
44
+ * @returns {boolean}
45
+ */
46
+ export function minimatch(str, pattern, options = {}) {
47
+ const { dot = false, nocase = true } = options;
48
+
49
+ // Normalize path separators
50
+ const normalizedStr = str.replace(/\\/g, '/');
51
+ const normalizedPattern = pattern.replace(/\\/g, '/');
52
+
53
+ // Handle negation
54
+ if (normalizedPattern.startsWith('!')) {
55
+ return !minimatch(normalizedStr, normalizedPattern.slice(1), options);
56
+ }
57
+
58
+ // Skip dotfiles unless dot option is true
59
+ if (!dot && normalizedStr.split('/').some(part => part.startsWith('.'))) {
60
+ // Allow if pattern explicitly matches dotfiles
61
+ if (!normalizedPattern.includes('/.') && !normalizedPattern.startsWith('.')) {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ try {
67
+ const regex = globToRegex(normalizedPattern);
68
+ if (nocase) {
69
+ return new RegExp(regex.source, 'i').test(normalizedStr);
70
+ }
71
+ return regex.test(normalizedStr);
72
+ } catch {
73
+ // Fallback to simple string matching
74
+ return normalizedStr === normalizedPattern;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Find all matching items from a list
80
+ * @param {string[]} list - List of strings to match against
81
+ * @param {string} pattern - Glob pattern
82
+ * @param {Object} [options] - Match options
83
+ * @returns {string[]}
84
+ */
85
+ export function minimatchFilter(list, pattern, options = {}) {
86
+ return list.filter(item => minimatch(item, pattern, options));
87
+ }
88
+
89
+ /**
90
+ * Test if string matches any of the patterns
91
+ * @param {string} str - String to test
92
+ * @param {string[]} patterns - Array of glob patterns
93
+ * @param {Object} [options] - Match options
94
+ * @returns {boolean}
95
+ */
96
+ export function minimatchAny(str, patterns, options = {}) {
97
+ return patterns.some(pattern => minimatch(str, pattern, options));
98
+ }
99
+
100
+ /**
101
+ * Test if string matches all of the patterns
102
+ * @param {string} str - String to test
103
+ * @param {string[]} patterns - Array of glob patterns
104
+ * @param {Object} [options] - Match options
105
+ * @returns {boolean}
106
+ */
107
+ export function minimatchAll(str, patterns, options = {}) {
108
+ return patterns.every(pattern => minimatch(str, pattern, options));
109
+ }
110
+
111
+ /**
112
+ * Create a matcher function for a pattern
113
+ * @param {string} pattern - Glob pattern
114
+ * @param {Object} [options] - Match options
115
+ * @returns {Function} Matcher function
116
+ */
117
+ export function createMatcher(pattern, options = {}) {
118
+ return (str) => minimatch(str, pattern, options);
119
+ }
120
+
121
+ /**
122
+ * Match file extension
123
+ * @param {string} fileName - File name or path
124
+ * @param {string|string[]} extensions - Extension(s) to match (with or without dot)
125
+ * @returns {boolean}
126
+ */
127
+ export function matchExtension(fileName, extensions) {
128
+ const exts = Array.isArray(extensions) ? extensions : [extensions];
129
+ const normalizedExts = exts.map(ext => ext.startsWith('.') ? ext : `.${ext}`);
130
+ const fileExt = fileName.includes('.') ? '.' + fileName.split('.').pop() : '';
131
+
132
+ return normalizedExts.some(ext => ext.toLowerCase() === fileExt.toLowerCase());
133
+ }
134
+
135
+ /**
136
+ * Match path prefix
137
+ * @param {string} filePath - File path
138
+ * @param {string|string[]} prefixes - Path prefix(es) to match
139
+ * @returns {boolean}
140
+ */
141
+ export function matchPathPrefix(filePath, prefixes) {
142
+ const prfxs = Array.isArray(prefixes) ? prefixes : [prefixes];
143
+ const normalizedPath = filePath.replace(/\\/g, '/');
144
+
145
+ return prfxs.some(prefix => {
146
+ const normalizedPrefix = prefix.replace(/\\/g, '/');
147
+ return normalizedPath.startsWith(normalizedPrefix);
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Extract file parts for matching
153
+ * @param {string} filePath - File path
154
+ * @returns {Object}
155
+ */
156
+ export function parseFilePath(filePath) {
157
+ const normalized = filePath.replace(/\\/g, '/');
158
+ const parts = normalized.split('/');
159
+ const fileName = parts.pop() || '';
160
+ const dirPath = parts.join('/');
161
+ const extMatch = fileName.match(/\.([^.]+)$/);
162
+ const extension = extMatch ? extMatch[1] : '';
163
+ const baseName = extension ? fileName.slice(0, -(extension.length + 1)) : fileName;
164
+
165
+ return {
166
+ fullPath: normalized,
167
+ dirPath,
168
+ fileName,
169
+ baseName,
170
+ extension,
171
+ depth: parts.length
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Score a match for prioritization
177
+ * Higher score = better match
178
+ * @param {string} str - String that matched
179
+ * @param {string} pattern - Pattern that matched
180
+ * @returns {number}
181
+ */
182
+ export function scoreMatch(str, pattern) {
183
+ let score = 0;
184
+
185
+ // Exact match = highest score
186
+ if (str === pattern) {
187
+ score += 100;
188
+ }
189
+
190
+ // Shorter patterns = more specific = higher score
191
+ const patternComplexity = (pattern.match(/\*/g) || []).length;
192
+ score += 50 - patternComplexity * 10;
193
+
194
+ // File extension patterns are more specific
195
+ if (pattern.startsWith('*.') && !pattern.includes('/')) {
196
+ score += 20;
197
+ }
198
+
199
+ // Path patterns with specific directories
200
+ if (pattern.includes('/') && !pattern.startsWith('**/')) {
201
+ score += 15;
202
+ }
203
+
204
+ return Math.max(0, score);
205
+ }
206
+
207
+ export default {
208
+ globToRegex,
209
+ minimatch,
210
+ minimatchFilter,
211
+ minimatchAny,
212
+ minimatchAll,
213
+ createMatcher,
214
+ matchExtension,
215
+ matchPathPrefix,
216
+ parseFilePath,
217
+ scoreMatch
218
+ };
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Pattern Registry - Manages pattern storage and retrieval
3
+ *
4
+ * Patterns are organized in categories:
5
+ * - frontend/ (react, vue, svelte, styling, component-libs)
6
+ * - backend/ (express, fastapi, database, auth)
7
+ * - universal/ (typescript, testing, error-handling, git, build-tools)
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
11
+ import { join, relative, basename, dirname } from 'path';
12
+
13
+ /**
14
+ * Pattern entry structure
15
+ * @typedef {Object} Pattern
16
+ * @property {string} id - Unique identifier (category/name)
17
+ * @property {string} name - Human-readable name
18
+ * @property {string} category - Pattern category (frontend, backend, universal)
19
+ * @property {string} path - Relative path to pattern directory
20
+ * @property {string} description - Pattern description
21
+ * @property {string[]} triggers - Associated trigger IDs
22
+ * @property {Object} metadata - Additional metadata
23
+ * @property {number} metadata.usageCount - How many times pattern was applied
24
+ * @property {string} metadata.lastUsed - ISO timestamp of last usage
25
+ * @property {string} metadata.createdAt - ISO timestamp of creation
26
+ */
27
+
28
+ /**
29
+ * PatternRegistry - Singleton for managing patterns
30
+ */
31
+ export class PatternRegistry {
32
+ constructor(workspacePath) {
33
+ this.workspacePath = workspacePath;
34
+ this.aiPath = join(workspacePath, '.ai');
35
+ this.patternsPath = join(this.aiPath, 'patterns');
36
+ this.registryPath = join(this.patternsPath, 'registry.json');
37
+ this.patterns = new Map();
38
+ this.loaded = false;
39
+ }
40
+
41
+ /**
42
+ * Initialize or load the registry
43
+ */
44
+ async load() {
45
+ if (this.loaded) return;
46
+
47
+ // Create patterns directory if it doesn't exist
48
+ if (!existsSync(this.patternsPath)) {
49
+ mkdirSync(this.patternsPath, { recursive: true });
50
+ }
51
+
52
+ // Load existing registry or scan for patterns
53
+ if (existsSync(this.registryPath)) {
54
+ try {
55
+ const data = JSON.parse(readFileSync(this.registryPath, 'utf8'));
56
+ for (const pattern of data.patterns || []) {
57
+ this.patterns.set(pattern.id, pattern);
58
+ }
59
+ } catch (error) {
60
+ console.warn('⚠️ Could not load registry, scanning patterns directory...');
61
+ await this.scan();
62
+ }
63
+ } else {
64
+ await this.scan();
65
+ }
66
+
67
+ this.loaded = true;
68
+ }
69
+
70
+ /**
71
+ * Scan patterns directory and build registry
72
+ */
73
+ async scan() {
74
+ this.patterns.clear();
75
+
76
+ const categories = ['frontend', 'backend', 'universal'];
77
+
78
+ for (const category of categories) {
79
+ const categoryPath = join(this.patternsPath, category);
80
+ if (!existsSync(categoryPath)) continue;
81
+
82
+ const entries = readdirSync(categoryPath, { withFileTypes: true });
83
+
84
+ for (const entry of entries) {
85
+ if (!entry.isDirectory()) continue;
86
+
87
+ const patternPath = join(categoryPath, entry.name);
88
+ const readmePath = join(patternPath, 'README.md');
89
+
90
+ const pattern = {
91
+ id: `${category}/${entry.name}`,
92
+ name: this.formatName(entry.name),
93
+ category,
94
+ path: relative(this.aiPath, patternPath),
95
+ description: this.extractDescription(readmePath),
96
+ triggers: [],
97
+ metadata: {
98
+ usageCount: 0,
99
+ lastUsed: null,
100
+ createdAt: new Date().toISOString()
101
+ }
102
+ };
103
+
104
+ this.patterns.set(pattern.id, pattern);
105
+ }
106
+ }
107
+
108
+ // Save the scanned registry
109
+ await this.save();
110
+ }
111
+
112
+ /**
113
+ * Extract description from README.md first line
114
+ */
115
+ extractDescription(readmePath) {
116
+ if (!existsSync(readmePath)) {
117
+ return 'No description available';
118
+ }
119
+
120
+ try {
121
+ const content = readFileSync(readmePath, 'utf8');
122
+ const lines = content.split('\n');
123
+
124
+ // Find first non-empty, non-heading line
125
+ for (const line of lines) {
126
+ const trimmed = line.trim();
127
+ if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) {
128
+ return trimmed.slice(0, 100) + (trimmed.length > 100 ? '...' : '');
129
+ }
130
+ }
131
+
132
+ return 'Pattern documentation available';
133
+ } catch {
134
+ return 'No description available';
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Format pattern name from directory name
140
+ */
141
+ formatName(dirName) {
142
+ return dirName
143
+ .split(/[-_]/)
144
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
145
+ .join(' ');
146
+ }
147
+
148
+ /**
149
+ * Save registry to disk
150
+ */
151
+ async save() {
152
+ const data = {
153
+ version: '1.0.0',
154
+ lastUpdated: new Date().toISOString(),
155
+ patterns: Array.from(this.patterns.values())
156
+ };
157
+
158
+ writeFileSync(this.registryPath, JSON.stringify(data, null, 2));
159
+ }
160
+
161
+ /**
162
+ * Get a pattern by ID
163
+ * @param {string} id - Pattern ID (e.g., 'frontend/react')
164
+ * @returns {Pattern|null}
165
+ */
166
+ get(id) {
167
+ return this.patterns.get(id) || null;
168
+ }
169
+
170
+ /**
171
+ * Get all patterns
172
+ * @returns {Pattern[]}
173
+ */
174
+ getAll() {
175
+ return Array.from(this.patterns.values());
176
+ }
177
+
178
+ /**
179
+ * Get patterns by category
180
+ * @param {string} category - Category name
181
+ * @returns {Pattern[]}
182
+ */
183
+ getByCategory(category) {
184
+ return this.getAll().filter(p => p.category === category);
185
+ }
186
+
187
+ /**
188
+ * Search patterns by name or description
189
+ * @param {string} query - Search query
190
+ * @returns {Pattern[]}
191
+ */
192
+ search(query) {
193
+ const lowerQuery = query.toLowerCase();
194
+ return this.getAll().filter(p =>
195
+ p.name.toLowerCase().includes(lowerQuery) ||
196
+ p.description.toLowerCase().includes(lowerQuery) ||
197
+ p.id.toLowerCase().includes(lowerQuery)
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Register a new pattern
203
+ * @param {Pattern} pattern - Pattern to register
204
+ */
205
+ async register(pattern) {
206
+ // Ensure required fields
207
+ if (!pattern.id || !pattern.name || !pattern.category) {
208
+ throw new Error('Pattern must have id, name, and category');
209
+ }
210
+
211
+ // Create pattern directory if it doesn't exist
212
+ const patternDir = join(this.patternsPath, pattern.path || pattern.id);
213
+ if (!existsSync(patternDir)) {
214
+ mkdirSync(patternDir, { recursive: true });
215
+
216
+ // Create README.md with description
217
+ const readmePath = join(patternDir, 'README.md');
218
+ writeFileSync(readmePath, `# ${pattern.name}\n\n${pattern.description || ''}\n`);
219
+ }
220
+
221
+ // Set metadata
222
+ pattern.metadata = pattern.metadata || {
223
+ usageCount: 0,
224
+ lastUsed: null,
225
+ createdAt: new Date().toISOString()
226
+ };
227
+
228
+ this.patterns.set(pattern.id, pattern);
229
+ await this.save();
230
+ }
231
+
232
+ /**
233
+ * Remove a pattern
234
+ * @param {string} id - Pattern ID
235
+ */
236
+ async remove(id) {
237
+ this.patterns.delete(id);
238
+ await this.save();
239
+ }
240
+
241
+ /**
242
+ * Record pattern usage
243
+ * @param {string} id - Pattern ID
244
+ */
245
+ async recordUsage(id) {
246
+ const pattern = this.patterns.get(id);
247
+ if (pattern) {
248
+ pattern.metadata.usageCount++;
249
+ pattern.metadata.lastUsed = new Date().toISOString();
250
+ await this.save();
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Get pattern content (README.md)
256
+ * @param {string} id - Pattern ID
257
+ * @returns {string|null}
258
+ */
259
+ getContent(id) {
260
+ const pattern = this.patterns.get(id);
261
+ if (!pattern) return null;
262
+
263
+ // pattern.path is relative to aiPath (e.g., "patterns/frontend/react")
264
+ const readmePath = join(this.aiPath, pattern.path, 'README.md');
265
+ if (!existsSync(readmePath)) return null;
266
+
267
+ return readFileSync(readmePath, 'utf8');
268
+ }
269
+
270
+ /**
271
+ * Get all files in a pattern directory
272
+ * @param {string} id - Pattern ID
273
+ * @returns {Object[]} Array of {name, path, content}
274
+ */
275
+ getPatternFiles(id) {
276
+ const pattern = this.patterns.get(id);
277
+ if (!pattern) return [];
278
+
279
+ // pattern.path is relative to aiPath (e.g., "patterns/frontend/react")
280
+ const patternDir = join(this.aiPath, pattern.path);
281
+ if (!existsSync(patternDir)) return [];
282
+
283
+ const files = [];
284
+ const entries = readdirSync(patternDir, { withFileTypes: true });
285
+
286
+ for (const entry of entries) {
287
+ if (entry.isFile()) {
288
+ const filePath = join(patternDir, entry.name);
289
+ files.push({
290
+ name: entry.name,
291
+ path: relative(this.aiPath, filePath),
292
+ content: readFileSync(filePath, 'utf8')
293
+ });
294
+ }
295
+ }
296
+
297
+ return files;
298
+ }
299
+
300
+ /**
301
+ * Get usage statistics
302
+ * @returns {Object}
303
+ */
304
+ getStats() {
305
+ const patterns = this.getAll();
306
+ const byCategory = {};
307
+ let totalUsage = 0;
308
+ let mostUsed = null;
309
+
310
+ for (const pattern of patterns) {
311
+ // Count by category
312
+ byCategory[pattern.category] = (byCategory[pattern.category] || 0) + 1;
313
+
314
+ // Track total usage
315
+ totalUsage += pattern.metadata.usageCount;
316
+
317
+ // Track most used
318
+ if (!mostUsed || pattern.metadata.usageCount > mostUsed.metadata.usageCount) {
319
+ mostUsed = pattern;
320
+ }
321
+ }
322
+
323
+ return {
324
+ totalPatterns: patterns.length,
325
+ byCategory,
326
+ totalUsage,
327
+ mostUsed: mostUsed ? { id: mostUsed.id, count: mostUsed.metadata.usageCount } : null
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Associate trigger with pattern
333
+ * @param {string} patternId - Pattern ID
334
+ * @param {string} triggerId - Trigger ID
335
+ */
336
+ async addTrigger(patternId, triggerId) {
337
+ const pattern = this.patterns.get(patternId);
338
+ if (pattern && !pattern.triggers.includes(triggerId)) {
339
+ pattern.triggers.push(triggerId);
340
+ await this.save();
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Remove trigger from pattern
346
+ * @param {string} patternId - Pattern ID
347
+ * @param {string} triggerId - Trigger ID
348
+ */
349
+ async removeTrigger(patternId, triggerId) {
350
+ const pattern = this.patterns.get(patternId);
351
+ if (pattern) {
352
+ pattern.triggers = pattern.triggers.filter(t => t !== triggerId);
353
+ await this.save();
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Create a new PatternRegistry instance for the given workspace
360
+ * @param {string} workspacePath - Path to workspace root
361
+ * @returns {PatternRegistry}
362
+ */
363
+ export function createRegistry(workspacePath) {
364
+ return new PatternRegistry(workspacePath);
365
+ }
366
+
367
+ /**
368
+ * Get pattern registry for current working directory
369
+ * @returns {PatternRegistry}
370
+ */
371
+ export function getRegistry() {
372
+ return createRegistry(process.cwd());
373
+ }
374
+
375
+ export default { PatternRegistry, createRegistry, getRegistry };