cursor-rules-to-github-copilot 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.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # cursor-rules-to-github-copilot
2
+
3
+ CLI that reads **Cursor** rules from `.cursor/rules` (`*.mdc` / `*.md`) and generates **GitHub Copilot** instruction files for **VS Code**:
4
+
5
+ | Cursor | Generated |
6
+ |--------|-----------|
7
+ | `alwaysApply: true` | `.github/copilot-instructions.md` (sections per rule) |
8
+ | `globs: ...` | `.github/instructions/*.instructions.md` with `applyTo` |
9
+ | No globs, not always | `.instructions.md` without `applyTo` (attach manually in chat) |
10
+
11
+ Copilot does not read `.cursor/rules` directly; run this tool after you change Cursor rules.
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ npx cursor-rules-to-github-copilot
17
+ ```
18
+
19
+ From another directory:
20
+
21
+ ```bash
22
+ npx cursor-rules-to-github-copilot --cwd /path/to/repo
23
+ ```
24
+
25
+ Options:
26
+
27
+ - `--cwd <dir>` — workspace root (default: current directory)
28
+ - `--rules-dir <rel>` — rules folder relative to cwd (default: `.cursor/rules`)
29
+ - `--dry-run` — show what would be written
30
+ - `--no-banner` — omit HTML comment banners in generated files
31
+
32
+ ## npm script (optional)
33
+
34
+ ```json
35
+ {
36
+ "scripts": {
37
+ "sync:copilot": "cursor-rules-to-github-copilot"
38
+ }
39
+ }
40
+ ```
41
+
42
+ Generated files under `.github/instructions/` are not pruned automatically. If you delete or rename a Cursor rule, remove the old `*.instructions.md` files yourself (or delete the folder and run the CLI again).
43
+
44
+ ## Requirements
45
+
46
+ - Node.js 18+
47
+
48
+ ## License
49
+
50
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("path");
5
+ const { convert } = require("../lib/convert");
6
+
7
+ function printHelp() {
8
+ console.log(`cursor-rules-to-github-copilot — Cursor .cursor/rules → GitHub Copilot instructions
9
+
10
+ Usage:
11
+ npx cursor-rules-to-github-copilot [options]
12
+
13
+ Options:
14
+ --cwd <dir> Workspace root (default: current directory)
15
+ --rules-dir <rel> Rules directory relative to cwd (default: .cursor/rules)
16
+ --dry-run Print actions without writing files
17
+ --no-banner Omit generator banner comments in output files
18
+ -h, --help Show help
19
+
20
+ Outputs:
21
+ .github/copilot-instructions.md — rules with alwaysApply: true
22
+ .github/instructions/*.instructions.md — globs → applyTo, or no applyTo if no globs
23
+ `);
24
+ }
25
+
26
+ async function main() {
27
+ const argv = process.argv.slice(2);
28
+ if (argv.includes("-h") || argv.includes("--help")) {
29
+ printHelp();
30
+ process.exit(0);
31
+ }
32
+
33
+ const opts = {
34
+ root: process.cwd(),
35
+ rulesDir: undefined,
36
+ dryRun: argv.includes("--dry-run"),
37
+ noBanner: argv.includes("--no-banner"),
38
+ };
39
+
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ if (a === "--cwd") {
43
+ opts.root = path.resolve(argv[++i] || "");
44
+ if (!argv[i]) {
45
+ console.error("cursor-rules-to-github-copilot: --cwd requires a path");
46
+ process.exit(1);
47
+ }
48
+ } else if (a === "--rules-dir") {
49
+ opts.rulesDir = argv[++i];
50
+ if (!opts.rulesDir) {
51
+ console.error("cursor-rules-to-github-copilot: --rules-dir requires a path");
52
+ process.exit(1);
53
+ }
54
+ }
55
+ }
56
+
57
+ try {
58
+ const result = await convert(opts);
59
+ console.log(result.message);
60
+ if (result.dryRun) {
61
+ for (const w of result.scopedWrites) {
62
+ console.log(" write:", path.relative(opts.root, w.dest));
63
+ }
64
+ if (result.alwaysPath) {
65
+ console.log(" write:", path.relative(opts.root, result.alwaysPath));
66
+ }
67
+ }
68
+ } catch (e) {
69
+ console.error("cursor-rules-to-github-copilot:", e.message || e);
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ main();
package/lib/convert.js ADDED
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs/promises");
4
+ const path = require("path");
5
+ const matter = require("gray-matter");
6
+
7
+ const DEFAULT_RULES_DIR = ".cursor/rules";
8
+ const OUT_INSTRUCTIONS = ".github/instructions";
9
+ const OUT_ALWAYS = ".github/copilot-instructions.md";
10
+
11
+ const BANNER = `<!-- Generated by cursor-rules-to-github-copilot — re-run after changing Cursor rules. -->\n\n`;
12
+
13
+ /**
14
+ * @param {string} dir
15
+ * @returns {Promise<string[]>}
16
+ */
17
+ async function walkFiles(dir) {
18
+ let entries;
19
+ try {
20
+ entries = await fs.readdir(dir, { withFileTypes: true });
21
+ } catch (e) {
22
+ if (e && e.code === "ENOENT") return [];
23
+ throw e;
24
+ }
25
+ const out = [];
26
+ for (const ent of entries) {
27
+ const full = path.join(dir, ent.name);
28
+ if (ent.isDirectory()) {
29
+ out.push(...(await walkFiles(full)));
30
+ } else if (ent.isFile()) {
31
+ out.push(full);
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * @param {unknown} v
39
+ * @returns {string[]}
40
+ */
41
+ function normalizeGlobs(v) {
42
+ if (v == null) return [];
43
+ if (Array.isArray(v)) {
44
+ return v.map(String).map((s) => s.trim()).filter(Boolean);
45
+ }
46
+ const s = String(v).trim();
47
+ if (!s) return [];
48
+ return [s];
49
+ }
50
+
51
+ /**
52
+ * @param {unknown} v
53
+ * @returns {boolean}
54
+ */
55
+ function isAlwaysApply(v) {
56
+ if (v === true) return true;
57
+ if (v === false || v == null) return false;
58
+ if (typeof v === "string") {
59
+ const t = v.trim().toLowerCase();
60
+ return t === "true" || t === "yes" || t === "1";
61
+ }
62
+ return Boolean(v);
63
+ }
64
+
65
+ /**
66
+ * Stable slug from rules-relative path for filenames.
67
+ * @param {string} rel - posix-style relative path without leading ./
68
+ */
69
+ function slugFromRel(rel) {
70
+ const noExt = rel.replace(/\.(mdc|md)$/i, "");
71
+ return noExt.replace(/\\/g, "/").replace(/\//g, "-");
72
+ }
73
+
74
+ /**
75
+ * @param {object} opts
76
+ * @param {string} opts.root - workspace root
77
+ * @param {string} [opts.rulesDir]
78
+ * @param {boolean} [opts.dryRun]
79
+ * @param {boolean} [opts.noBanner]
80
+ */
81
+ async function convert(opts) {
82
+ const root = path.resolve(opts.root);
83
+ const rulesDir = path.join(root, opts.rulesDir || DEFAULT_RULES_DIR);
84
+ const dryRun = Boolean(opts.dryRun);
85
+ const noBanner = Boolean(opts.noBanner);
86
+
87
+ const allFiles = await walkFiles(rulesDir);
88
+ const ruleFiles = allFiles
89
+ .filter((f) => /\.(mdc|md)$/i.test(f))
90
+ .sort((a, b) => a.localeCompare(b));
91
+
92
+ if (ruleFiles.length === 0) {
93
+ return {
94
+ ok: true,
95
+ message: `No rule files found under ${path.relative(root, rulesDir) || "."}`,
96
+ alwaysParts: [],
97
+ scopedWrites: [],
98
+ dryRun,
99
+ };
100
+ }
101
+
102
+ /** @type {{ source: string, body: string, description?: string }[]} */
103
+ const alwaysParts = [];
104
+ /** @type {{ dest: string, content: string }[]} */
105
+ const scopedWrites = [];
106
+
107
+ for (const abs of ruleFiles) {
108
+ const raw = await fs.readFile(abs, "utf8");
109
+ const parsed = matter(raw);
110
+ const data = parsed.data || {};
111
+ const rel = path.relative(rulesDir, abs).split(path.sep).join("/");
112
+ const slug = slugFromRel(rel);
113
+ const description =
114
+ typeof data.description === "string" ? data.description.trim() : "";
115
+ const globs = normalizeGlobs(data.globs);
116
+ const always = isAlwaysApply(data.alwaysApply);
117
+
118
+ const header = `<!-- Source: ${DEFAULT_RULES_DIR}/${rel} -->\n`;
119
+
120
+ if (always) {
121
+ let block = header;
122
+ if (description) {
123
+ block += `> ${description.replace(/\n/g, "\n> ")}\n\n`;
124
+ }
125
+ block += parsed.content.trim() ? `${parsed.content.trim()}\n\n` : "";
126
+ alwaysParts.push({
127
+ source: `${DEFAULT_RULES_DIR}/${rel}`,
128
+ body: block,
129
+ description,
130
+ });
131
+ continue;
132
+ }
133
+
134
+ if (globs.length === 0) {
135
+ // Cursor "intelligent" / manual: emit instructions file without applyTo (attach manually in VS Code).
136
+ const name = `${slug}.instructions.md`;
137
+ const dest = path.join(root, OUT_INSTRUCTIONS, name);
138
+ const fm = [
139
+ "---",
140
+ description ? `description: ${JSON.stringify(description)}` : null,
141
+ `name: ${JSON.stringify(slug)}`,
142
+ "---",
143
+ "",
144
+ ]
145
+ .filter(Boolean)
146
+ .join("\n");
147
+ const content = (noBanner ? "" : BANNER) + fm + "\n" + header + (parsed.content.trim() || "(empty rule body)") + "\n";
148
+ scopedWrites.push({ dest, content });
149
+ continue;
150
+ }
151
+
152
+ globs.forEach((g, i) => {
153
+ const suffix = globs.length > 1 ? `-${i + 1}` : "";
154
+ const name = `${slug}${suffix}.instructions.md`;
155
+ const dest = path.join(root, OUT_INSTRUCTIONS, name);
156
+ const fm = [
157
+ "---",
158
+ `name: ${JSON.stringify(`${slug}${suffix}`)}`,
159
+ description ? `description: ${JSON.stringify(description)}` : null,
160
+ `applyTo: ${JSON.stringify(g)}`,
161
+ "---",
162
+ "",
163
+ ]
164
+ .filter(Boolean)
165
+ .join("\n");
166
+ const content =
167
+ (noBanner ? "" : BANNER) +
168
+ fm +
169
+ "\n" +
170
+ header +
171
+ (parsed.content.trim() || "(empty rule body)") +
172
+ "\n";
173
+ scopedWrites.push({ dest, content });
174
+ });
175
+ }
176
+
177
+ let alwaysDoc = "";
178
+ if (alwaysParts.length > 0) {
179
+ alwaysDoc =
180
+ (noBanner ? "" : BANNER) +
181
+ "# Project instructions (from Cursor rules)\n\n" +
182
+ alwaysParts
183
+ .map(
184
+ (p) =>
185
+ `## ${p.source}\n\n${p.body.trim()}\n`
186
+ )
187
+ .join("\n");
188
+ }
189
+
190
+ if (!dryRun) {
191
+ const outInst = path.join(root, OUT_INSTRUCTIONS);
192
+ await fs.mkdir(outInst, { recursive: true });
193
+ await fs.mkdir(path.join(root, ".github"), { recursive: true });
194
+
195
+ for (const { dest, content } of scopedWrites) {
196
+ await fs.mkdir(path.dirname(dest), { recursive: true });
197
+ await fs.writeFile(dest, content, "utf8");
198
+ }
199
+
200
+ const alwaysPath = path.join(root, OUT_ALWAYS);
201
+ if (alwaysDoc) {
202
+ await fs.writeFile(alwaysPath, alwaysDoc, "utf8");
203
+ } else {
204
+ try {
205
+ await fs.unlink(alwaysPath);
206
+ } catch (e) {
207
+ if (e && e.code !== "ENOENT") throw e;
208
+ }
209
+ }
210
+ }
211
+
212
+ return {
213
+ ok: true,
214
+ message: `Processed ${ruleFiles.length} rule file(s).`,
215
+ alwaysParts,
216
+ scopedWrites,
217
+ alwaysPath: alwaysDoc ? path.join(root, OUT_ALWAYS) : null,
218
+ dryRun,
219
+ };
220
+ }
221
+
222
+ module.exports = { convert, walkFiles, DEFAULT_RULES_DIR, OUT_ALWAYS, OUT_INSTRUCTIONS };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "cursor-rules-to-github-copilot",
3
+ "version": "0.1.0",
4
+ "description": "Convert Cursor .cursor/rules (*.mdc) to GitHub Copilot instruction files for VS Code",
5
+ "license": "MIT",
6
+ "author": "",
7
+ "keywords": [
8
+ "cursor",
9
+ "github-copilot",
10
+ "vscode",
11
+ "rules",
12
+ "copilot-instructions"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "bin": {
18
+ "cursor-rules-to-github-copilot": "bin/cli.js"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "lib",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "start": "node bin/cli.js",
27
+ "test": "node --test test/*.test.js"
28
+ },
29
+ "dependencies": {
30
+ "gray-matter": "^4.0.3"
31
+ }
32
+ }