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 +50 -0
- package/bin/cli.js +74 -0
- package/lib/convert.js +222 -0
- package/package.json +32 -0
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
|
+
}
|