@zebralabs/context-cli 0.1.3 → 0.1.5

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.
@@ -0,0 +1,119 @@
1
+ import { IAssetGenerator } from "../../../application/ports/asset-generator.js";
2
+ import { FileWriter } from "../../file-system/file-writer.js";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Cursor Rules Generator Adapter
7
+ * Generates .cursor/rules/*.md files from consolidated rules
8
+ */
9
+ export class CursorRulesGenerator extends IAssetGenerator {
10
+ constructor() {
11
+ super();
12
+ this.fileWriter = new FileWriter();
13
+ }
14
+
15
+ getToolName() {
16
+ return "cursor";
17
+ }
18
+
19
+ async generate(compilation, outputPath) {
20
+ const rulesDir = path.join(outputPath, ".cursor", "rules");
21
+ this.fileWriter.ensureDir(rulesDir);
22
+
23
+ // Generate index.md
24
+ const indexContent = this.generateIndex(compilation);
25
+ this.fileWriter.writeFile(path.join(rulesDir, "index.md"), indexContent);
26
+
27
+ // Generate one file per category
28
+ const categories = compilation.getCategories();
29
+ const allFiles = ["index.md"];
30
+ for (const category of categories) {
31
+ const rules = compilation.getRulesByCategory(category);
32
+ const categoryContent = this.generateCategoryFile(category, rules, compilation);
33
+ const fileName = `${category}.md`;
34
+ this.fileWriter.writeFile(path.join(rulesDir, fileName), categoryContent);
35
+ allFiles.push(fileName);
36
+ }
37
+
38
+ return {
39
+ filesGenerated: allFiles,
40
+ outputDir: rulesDir
41
+ };
42
+ }
43
+
44
+ merge(assets) {
45
+ // For MVP, just return assets as-is
46
+ // In Phase 2, implement proper merging logic
47
+ return assets;
48
+ }
49
+
50
+ /**
51
+ * Generate index.md for Cursor rules
52
+ */
53
+ generateIndex(compilation) {
54
+ const lines = [];
55
+ lines.push("# Cursor Rules");
56
+ lines.push("");
57
+ lines.push("This directory contains rules extracted from knowledge packs.");
58
+ lines.push("");
59
+ lines.push("## Categories");
60
+ lines.push("");
61
+
62
+ const categories = compilation.getCategories();
63
+ for (const category of categories) {
64
+ const rules = compilation.getRulesByCategory(category);
65
+ lines.push(`- [${category}](./${category}.md) (${rules.length} rules)`);
66
+ }
67
+
68
+ lines.push("");
69
+ lines.push("## Quick Reference");
70
+ lines.push("");
71
+ lines.push(`Total Rules: ${compilation.rules.length}`);
72
+ lines.push("");
73
+ lines.push("For detailed consolidated standards, see:");
74
+ lines.push("- [CONSOLIDATED-STANDARDS.md](../CONSOLIDATED-STANDARDS.md)");
75
+ lines.push("- [RULES-INDEX.md](../RULES-INDEX.md)");
76
+
77
+ return lines.join("\n");
78
+ }
79
+
80
+ /**
81
+ * Generate category file
82
+ */
83
+ generateCategoryFile(category, rules, compilation) {
84
+ const lines = [];
85
+ lines.push(`# ${category} Rules`);
86
+ lines.push("");
87
+ lines.push(`Rules in the ${category} category.`);
88
+ lines.push("");
89
+
90
+ // Sort by level, then ID
91
+ const levelOrder = { must: 0, should: 1, prefer: 2, avoid: 3 };
92
+ rules.sort((a, b) => {
93
+ const levelDiff = levelOrder[a.level] - levelOrder[b.level];
94
+ if (levelDiff !== 0) return levelDiff;
95
+ return a.id.localeCompare(b.id);
96
+ });
97
+
98
+ for (const rule of rules) {
99
+ lines.push(`## ${rule.id}`);
100
+ lines.push("");
101
+ lines.push(`**Level:** ${rule.level}`);
102
+ lines.push(`**Applies to:** ${rule.appliesTo.join(", ")}`);
103
+ lines.push("");
104
+ lines.push(rule.rule);
105
+ lines.push("");
106
+ if (rule.rationale) {
107
+ lines.push(`*Rationale:* ${rule.rationale}`);
108
+ lines.push("");
109
+ }
110
+ }
111
+
112
+ lines.push("---");
113
+ lines.push("");
114
+ lines.push("[Back to Rules Index](./index.md)");
115
+
116
+ return lines.join("\n");
117
+ }
118
+ }
119
+
@@ -0,0 +1,115 @@
1
+ import { IAssetGenerator } from "../../../application/ports/asset-generator.js";
2
+ import { FileWriter } from "../../file-system/file-writer.js";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Cursor Skills Generator Adapter
7
+ * Generates .cursor\skills\*\SKILL.md files from skill definitions
8
+ */
9
+ export class CursorSkillsGenerator extends IAssetGenerator {
10
+ constructor() {
11
+ super();
12
+ this.fileWriter = new FileWriter();
13
+ }
14
+
15
+ getToolName() {
16
+ return "cursor-skills";
17
+ }
18
+
19
+ async generate(compilation, outputPath, skills) {
20
+ if (!skills || skills.length === 0) {
21
+ return { filesGenerated: [], outputDir: null };
22
+ }
23
+
24
+ const skillsDir = path.join(outputPath, ".cursor", "skills");
25
+ this.fileWriter.ensureDir(skillsDir);
26
+
27
+ const generated = [];
28
+
29
+ for (const skill of skills) {
30
+ const skillDir = path.join(skillsDir, skill.name);
31
+ this.fileWriter.ensureDir(skillDir);
32
+
33
+ const skillContent = this.generateSkillFile(skill, compilation);
34
+ this.fileWriter.writeFile(path.join(skillDir, "SKILL.md"), skillContent);
35
+
36
+ generated.push(skill.name);
37
+ }
38
+
39
+ return {
40
+ filesGenerated: generated.map(s => `${s}/SKILL.md`),
41
+ outputDir: skillsDir
42
+ };
43
+ }
44
+
45
+ merge(assets) {
46
+ // For MVP, just return assets as-is
47
+ return assets;
48
+ }
49
+
50
+ /**
51
+ * Generate SKILL.md file for a skill
52
+ */
53
+ generateSkillFile(skill, compilation) {
54
+ const lines = [];
55
+
56
+ // YAML frontmatter
57
+ lines.push("---");
58
+ lines.push(`name: ${skill.name}`);
59
+ lines.push(`description: ${skill.description}`);
60
+ lines.push("---");
61
+ lines.push("");
62
+ lines.push(`# ${skill.name}`);
63
+ lines.push("");
64
+ lines.push(skill.description);
65
+ lines.push("");
66
+
67
+ // When to Use section
68
+ if (skill.whenToUse && skill.whenToUse.length > 0) {
69
+ lines.push("## When to Use");
70
+ lines.push("");
71
+ for (const useCase of skill.whenToUse) {
72
+ lines.push(`- ${useCase}`);
73
+ }
74
+ lines.push("");
75
+ }
76
+
77
+ // Rules section
78
+ if (skill.rules && skill.rules.length > 0) {
79
+ lines.push("## Rules");
80
+ lines.push("");
81
+ for (const ruleId of skill.rules) {
82
+ const rule = compilation.rules.find(r => r.id === ruleId);
83
+ if (rule) {
84
+ lines.push(`### ${rule.id}`);
85
+ lines.push(`- **Level:** ${rule.level}`);
86
+ lines.push(`- **Rule:** ${rule.rule}`);
87
+ if (rule.rationale) {
88
+ lines.push(`- **Rationale:** ${rule.rationale}`);
89
+ }
90
+ lines.push("");
91
+ } else {
92
+ lines.push(`### ${ruleId}`);
93
+ lines.push(`- *Rule not found in compilation*`);
94
+ lines.push("");
95
+ }
96
+ }
97
+ }
98
+
99
+ // Process reference
100
+ if (skill.processReference) {
101
+ lines.push("## Process");
102
+ lines.push("");
103
+ // If processReference already includes "See", use as-is; otherwise add it
104
+ if (skill.processReference.startsWith("See ")) {
105
+ lines.push(`${skill.processReference}.`);
106
+ } else {
107
+ lines.push(`See ${skill.processReference} in the source documentation.`);
108
+ }
109
+ lines.push("");
110
+ }
111
+
112
+ return lines.join("\n");
113
+ }
114
+ }
115
+
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * File reader adapter
6
+ * Handles file system operations
7
+ */
8
+ export class FileReader {
9
+ /**
10
+ * Read a file and return its contents
11
+ * @param {string} filePath - Path to file
12
+ * @returns {string} File contents
13
+ */
14
+ readFile(filePath) {
15
+ return fs.readFileSync(filePath, "utf8");
16
+ }
17
+
18
+ /**
19
+ * Check if a file exists
20
+ * @param {string} filePath - Path to file
21
+ * @returns {boolean} True if exists
22
+ */
23
+ exists(filePath) {
24
+ return fs.existsSync(filePath);
25
+ }
26
+
27
+ /**
28
+ * List all markdown files recursively
29
+ * @param {string} dirPath - Directory to search
30
+ * @returns {string[]} Array of file paths
31
+ */
32
+ listMarkdownFiles(dirPath) {
33
+ const files = [];
34
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
35
+
36
+ for (const entry of entries) {
37
+ const fullPath = path.join(dirPath, entry.name);
38
+
39
+ // Skip hidden files and common exclusions
40
+ if (entry.name.startsWith(".")) continue;
41
+ if (entry.name === "node_modules") continue;
42
+
43
+ if (entry.isDirectory()) {
44
+ files.push(...this.listMarkdownFiles(fullPath));
45
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
46
+ files.push(fullPath);
47
+ }
48
+ }
49
+
50
+ return files;
51
+ }
52
+
53
+ /**
54
+ * Get file metadata
55
+ * @param {string} filePath - Path to file
56
+ * @returns {Object} File metadata
57
+ */
58
+ getFileMetadata(filePath) {
59
+ const stats = fs.statSync(filePath);
60
+ return {
61
+ path: filePath,
62
+ size: stats.size,
63
+ modified: stats.mtime.toISOString()
64
+ };
65
+ }
66
+ }
67
+
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * File writer adapter
6
+ * Handles file system write operations
7
+ */
8
+ export class FileWriter {
9
+ /**
10
+ * Write content to a file
11
+ * @param {string} filePath - Path to file
12
+ * @param {string} content - Content to write
13
+ */
14
+ writeFile(filePath, content) {
15
+ // Ensure directory exists
16
+ const dir = path.dirname(filePath);
17
+ fs.mkdirSync(dir, { recursive: true });
18
+
19
+ fs.writeFileSync(filePath, content, "utf8");
20
+ }
21
+
22
+ /**
23
+ * Write JSON to a file
24
+ * @param {string} filePath - Path to file
25
+ * @param {Object} data - Data to write as JSON
26
+ */
27
+ writeJson(filePath, data) {
28
+ const content = JSON.stringify(data, null, 2);
29
+ this.writeFile(filePath, content);
30
+ }
31
+
32
+ /**
33
+ * Ensure directory exists
34
+ * @param {string} dirPath - Directory path
35
+ */
36
+ ensureDir(dirPath) {
37
+ fs.mkdirSync(dirPath, { recursive: true });
38
+ }
39
+ }
40
+
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Markdown parser adapter
3
+ * Parses markdown files and extracts structured sections
4
+ */
5
+ export class MarkdownParser {
6
+ /**
7
+ * Strip YAML frontmatter from markdown
8
+ * @param {string} content - Markdown content
9
+ * @returns {string} Content without frontmatter
10
+ */
11
+ stripFrontmatter(content) {
12
+ if (!content.startsWith("---\n")) return content;
13
+ const endIndex = content.indexOf("\n---\n", 4);
14
+ if (endIndex === -1) return content;
15
+ return content.substring(endIndex + 5);
16
+ }
17
+
18
+ /**
19
+ * Parse YAML frontmatter
20
+ * @param {string} content - Markdown content
21
+ * @returns {Object|null} Parsed frontmatter or null
22
+ */
23
+ parseFrontmatter(content) {
24
+ if (!content.startsWith("---\n")) return null;
25
+ const endIndex = content.indexOf("\n---\n", 4);
26
+ if (endIndex === -1) return null;
27
+
28
+ const frontmatter = content.substring(4, endIndex);
29
+ // Simple YAML parsing (for now, can enhance later)
30
+ const result = {};
31
+ for (const line of frontmatter.split("\n")) {
32
+ const match = line.match(/^(\w+):\s*(.+)$/);
33
+ if (match) {
34
+ const key = match[1];
35
+ let value = match[2].trim();
36
+ // Remove quotes if present
37
+ if ((value.startsWith('"') && value.endsWith('"')) ||
38
+ (value.startsWith("'") && value.endsWith("'"))) {
39
+ value = value.slice(1, -1);
40
+ }
41
+ result[key] = value;
42
+ }
43
+ }
44
+ return result;
45
+ }
46
+
47
+ /**
48
+ * Find a section by heading
49
+ * @param {string} content - Markdown content
50
+ * @param {string} heading - Heading to find (e.g., "## @context.rules")
51
+ * @returns {string|null} Section content or null if not found
52
+ */
53
+ findSection(content, heading) {
54
+ const lines = content.split("\n");
55
+ let startIndex = -1;
56
+ let endIndex = lines.length;
57
+
58
+ // Find start of section
59
+ for (let i = 0; i < lines.length; i++) {
60
+ if (lines[i].trim() === heading) {
61
+ startIndex = i + 1;
62
+ break;
63
+ }
64
+ }
65
+
66
+ if (startIndex === -1) return null;
67
+
68
+ // Find end of section (next ## heading or end of file)
69
+ for (let i = startIndex; i < lines.length; i++) {
70
+ if (lines[i].startsWith("## ") && lines[i].trim() !== heading) {
71
+ endIndex = i;
72
+ break;
73
+ }
74
+ }
75
+
76
+ return lines.slice(startIndex, endIndex).join("\n");
77
+ }
78
+
79
+ /**
80
+ * Get line number where a string appears
81
+ * @param {string} content - Full content
82
+ * @param {string} searchString - String to find
83
+ * @returns {number} Line number (1-indexed) or -1 if not found
84
+ */
85
+ getLineNumber(content, searchString) {
86
+ const lines = content.split("\n");
87
+ for (let i = 0; i < lines.length; i++) {
88
+ if (lines[i].includes(searchString)) {
89
+ return i + 1;
90
+ }
91
+ }
92
+ return -1;
93
+ }
94
+ }
95
+
@@ -0,0 +1,219 @@
1
+ import { Rule } from "../../domain/rule.js";
2
+ import { Preference } from "../../domain/preference.js";
3
+ import { Scope } from "../../domain/scope.js";
4
+ import { MarkdownParser } from "./markdown-parser.js";
5
+
6
+ /**
7
+ * Rule extractor adapter
8
+ * Extracts rules, preferences, and scope from markdown files
9
+ */
10
+ export class RuleExtractor {
11
+ constructor() {
12
+ this.parser = new MarkdownParser();
13
+ }
14
+
15
+ /**
16
+ * Extract rules from markdown content
17
+ * @param {string} content - Markdown content
18
+ * @param {Object} source - Source information
19
+ * @returns {Rule[]} Extracted rules
20
+ */
21
+ extractRules(content, source) {
22
+ const rules = [];
23
+ const rulesSection = this.parser.findSection(content, "## @context.rules");
24
+
25
+ if (!rulesSection) return rules;
26
+
27
+ const lines = rulesSection.split("\n");
28
+ let currentRule = null;
29
+
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const line = lines[i].trim();
32
+
33
+ // Rule header: ### rule-id: Title
34
+ const ruleHeaderMatch = line.match(/^###\s+([a-z0-9-]+):\s*(.+)$/);
35
+ if (ruleHeaderMatch) {
36
+ // Save previous rule if exists
37
+ if (currentRule) {
38
+ if (currentRule.rule) {
39
+ rules.push(this.createRule(currentRule, source, content));
40
+ }
41
+ }
42
+
43
+ // Start new rule
44
+ currentRule = {
45
+ id: ruleHeaderMatch[1],
46
+ title: ruleHeaderMatch[2],
47
+ level: null,
48
+ appliesTo: null,
49
+ rule: null,
50
+ rationale: null
51
+ };
52
+ continue;
53
+ }
54
+
55
+ if (!currentRule) continue;
56
+
57
+ // Parse rule fields
58
+ const levelMatch = line.match(/^-\s*\*\*Level:\*\*\s*(.+)$/);
59
+ if (levelMatch) {
60
+ currentRule.level = levelMatch[1].trim();
61
+ continue;
62
+ }
63
+
64
+ const appliesToMatch = line.match(/^-\s*\*\*Applies to:\*\*\s*(.+)$/);
65
+ if (appliesToMatch) {
66
+ const appliesToStr = appliesToMatch[1].trim();
67
+ currentRule.appliesTo = appliesToStr === "all"
68
+ ? ["all"]
69
+ : appliesToStr.split(",").map(s => s.trim());
70
+ continue;
71
+ }
72
+
73
+ const ruleMatch = line.match(/^-\s*\*\*Rule:\*\*\s*(.+)$/);
74
+ if (ruleMatch) {
75
+ currentRule.rule = ruleMatch[1].trim();
76
+ continue;
77
+ }
78
+
79
+ const rationaleMatch = line.match(/^-\s*\*\*Rationale:\*\*\s*(.+)$/);
80
+ if (rationaleMatch) {
81
+ currentRule.rationale = rationaleMatch[1].trim();
82
+ continue;
83
+ }
84
+ }
85
+
86
+ // Don't forget the last rule
87
+ if (currentRule && currentRule.rule) {
88
+ rules.push(this.createRule(currentRule, source, content));
89
+ }
90
+
91
+ return rules;
92
+ }
93
+
94
+ /**
95
+ * Create a Rule object from extracted data
96
+ * @param {Object} data - Extracted rule data
97
+ * @param {Object} source - Source information
98
+ * @param {string} content - Full content for line number calculation
99
+ * @returns {Rule} Rule object
100
+ */
101
+ createRule(data, source, content) {
102
+ // Validate level
103
+ if (!Rule.isValidLevel(data.level)) {
104
+ console.warn(`Invalid level "${data.level}" for rule ${data.id}, defaulting to "should"`);
105
+ data.level = "should";
106
+ }
107
+
108
+ // Validate rule ID
109
+ if (!Rule.isValidId(data.id)) {
110
+ throw new Error(`Invalid rule ID format: ${data.id} (must be lowercase, numbers, hyphens only)`);
111
+ }
112
+
113
+ // Get line number
114
+ const lineNumber = this.parser.getLineNumber(content, `### ${data.id}:`);
115
+
116
+ return new Rule({
117
+ id: data.id,
118
+ level: data.level,
119
+ category: Rule.extractCategory(data.id),
120
+ appliesTo: data.appliesTo || [],
121
+ rule: data.rule,
122
+ rationale: data.rationale,
123
+ source: {
124
+ pack: source.pack,
125
+ packVersion: source.packVersion,
126
+ file: source.file,
127
+ line: lineNumber,
128
+ precedence: source.precedence
129
+ }
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Extract preferences from markdown content
135
+ * @param {string} content - Markdown content
136
+ * @param {Object} source - Source information
137
+ * @returns {Preference[]} Extracted preferences
138
+ */
139
+ extractPreferences(content, source) {
140
+ const preferences = [];
141
+ const prefsSection = this.parser.findSection(content, "## @context.preferences");
142
+
143
+ if (!prefsSection) return preferences;
144
+
145
+ const lines = prefsSection.split("\n");
146
+ for (const line of lines) {
147
+ const match = line.match(/^-\s*\*\*prefer:\*\*\s*(.+?)(?:\s*-\s*(.+))?$/);
148
+ if (match) {
149
+ const name = match[1].trim();
150
+ const description = match[2] ? match[2].trim() : null;
151
+ const lineNumber = this.parser.getLineNumber(content, line.trim());
152
+
153
+ preferences.push(new Preference({
154
+ name,
155
+ description,
156
+ source: {
157
+ pack: source.pack,
158
+ packVersion: source.packVersion,
159
+ file: source.file,
160
+ line: lineNumber,
161
+ precedence: source.precedence
162
+ }
163
+ }));
164
+ }
165
+ }
166
+
167
+ return preferences;
168
+ }
169
+
170
+ /**
171
+ * Extract scope from markdown content
172
+ * @param {string} content - Markdown content
173
+ * @param {Object} source - Source information
174
+ * @returns {Scope|null} Extracted scope or null
175
+ */
176
+ extractScope(content, source) {
177
+ const scopeSection = this.parser.findSection(content, "## @context.scope");
178
+
179
+ if (!scopeSection) return null;
180
+
181
+ const appliesTo = [];
182
+ const doesNotApplyTo = [];
183
+ let inAppliesTo = false;
184
+ let inDoesNotApplyTo = false;
185
+
186
+ const lines = scopeSection.split("\n");
187
+ for (const line of lines) {
188
+ if (line.includes("**Applies to:**")) {
189
+ inAppliesTo = true;
190
+ inDoesNotApplyTo = false;
191
+ continue;
192
+ }
193
+ if (line.includes("**Does not apply to:**")) {
194
+ inAppliesTo = false;
195
+ inDoesNotApplyTo = true;
196
+ continue;
197
+ }
198
+
199
+ const listMatch = line.match(/^-\s*(.+)$/);
200
+ if (listMatch) {
201
+ const item = listMatch[1].trim();
202
+ if (inAppliesTo) {
203
+ appliesTo.push(item);
204
+ } else if (inDoesNotApplyTo) {
205
+ doesNotApplyTo.push(item);
206
+ }
207
+ }
208
+ }
209
+
210
+ return new Scope({
211
+ appliesTo,
212
+ doesNotApplyTo,
213
+ source: {
214
+ file: source.file
215
+ }
216
+ });
217
+ }
218
+ }
219
+