Skip to content

Commit 492dfd1

Browse files
authored
Merge pull request #1102 from r1bilski/copilot-opencode-global-rules
feat: Enable global for copilot and opencode rules
2 parents 2277206 + 8932220 commit 492dfd1

11 files changed

Lines changed: 534 additions & 82 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu
6969
| Claude Code | claudecode | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 ||
7070
| Codex CLI | codexcli | ✅ 🌏 | | 🌏 🔧 | 🌏 | 🎮 | ✅ 🌏 | |
7171
| Gemini CLI | geminicli | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | |
72-
| GitHub Copilot | copilot | | ||||| |
72+
| GitHub Copilot | copilot | ✅ 🌏 | ||||| |
7373
| Cursor | cursor |||| ✅ 🌏 | ✅ 🌏 | ✅ 🌏 ||
7474
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
75-
| OpenCode | opencode | | | ✅ 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
75+
| OpenCode | opencode | ✅ 🌏 | | ✅ 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
7676
| Cline | cline |||| ✅ 🌏 | | | |
7777
| Kilo Code | kilo | ✅ 🌏 ||| ✅ 🌏 | | ✅ 🌏 | |
7878
| Roo Code | roo ||||| 🎮 | ✅ 🌏 | |

docs/guide/global-mode.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
You can use global mode via Rulesync by enabling `--global` option. It can also be called as user scope mode.
44

5-
Currently, supports rules and commands generation for Claude Code. Import for global files is supported for rules and commands.
5+
Currently, supports rules generation for Claude Code, GitHub Copilot, and OpenCode. Import for global files is supported for rules and commands. Command generation in global mode remains Claude Code only.
66

77
1. Create an any name directory. For example, if you prefer `~/.aiglobal`, run the following command.
88

@@ -50,3 +50,4 @@ Currently, supports rules and commands generation for Claude Code. Import for gl
5050
> - `rulesync.jsonc` only supports `global`, `features`, `delete` and `verbose`. `Features` can be set `"rules"` and `"commands"`. Other parameters are ignored.
5151
> - `rules/*.md` only supports single file has `root: true`, and frontmatter parameters without `root` are ignored.
5252
> - Only Claude Code is supported for global mode commands.
53+
> - Global mode rules are supported for Claude Code, GitHub Copilot, and OpenCode.

docs/reference/supported-tools.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
99
| Claude Code | claudecode | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 ||
1010
| Codex CLI | codexcli | ✅ 🌏 | | 🌏 🔧 | 🌏 | 🎮 | ✅ 🌏 | |
1111
| Gemini CLI | geminicli | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | |
12-
| GitHub Copilot | copilot | | ||||| |
12+
| GitHub Copilot | copilot | ✅ 🌏 | ||||| |
1313
| Cursor | cursor |||| ✅ 🌏 | ✅ 🌏 | ✅ 🌏 ||
1414
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
15-
| OpenCode | opencode | | | ✅ 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
15+
| OpenCode | opencode | ✅ 🌏 | | ✅ 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
1616
| Cline | cline |||| ✅ 🌏 | | | |
1717
| Kilo Code | kilo | ✅ 🌏 ||| ✅ 🌏 | | ✅ 🌏 | |
1818
| Roo Code | roo ||||| 🎮 | ✅ 🌏 | |

skills/rulesync/global-mode.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
You can use global mode via Rulesync by enabling `--global` option. It can also be called as user scope mode.
44

5-
Currently, supports rules and commands generation for Claude Code. Import for global files is supported for rules and commands.
5+
Currently, supports rules generation for Claude Code, GitHub Copilot, and OpenCode. Import for global files is supported for rules and commands. Command generation in global mode remains Claude Code only.
66

77
1. Create an any name directory. For example, if you prefer `~/.aiglobal`, run the following command.
88

@@ -50,3 +50,4 @@ Currently, supports rules and commands generation for Claude Code. Import for gl
5050
> - `rulesync.jsonc` only supports `global`, `features`, `delete` and `verbose`. `Features` can be set `"rules"` and `"commands"`. Other parameters are ignored.
5151
> - `rules/*.md` only supports single file has `root: true`, and frontmatter parameters without `root` are ignored.
5252
> - Only Claude Code is supported for global mode commands.
53+
> - Global mode rules are supported for Claude Code, GitHub Copilot, and OpenCode.

skills/rulesync/supported-tools.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
99
| Claude Code | claudecode | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 ||
1010
| Codex CLI | codexcli | ✅ 🌏 | | 🌏 🔧 | 🌏 | 🎮 | ✅ 🌏 | |
1111
| Gemini CLI | geminicli | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | |
12-
| GitHub Copilot | copilot | | ||||| |
12+
| GitHub Copilot | copilot | ✅ 🌏 | ||||| |
1313
| Cursor | cursor |||| ✅ 🌏 | ✅ 🌏 | ✅ 🌏 ||
1414
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
15-
| OpenCode | opencode | | | ✅ 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
15+
| OpenCode | opencode | ✅ 🌏 | | ✅ 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
1616
| Cline | cline |||| ✅ 🌏 | | | |
1717
| Kilo Code | kilo | ✅ 🌏 ||| ✅ 🌏 | | ✅ 🌏 | |
1818
| Roo Code | roo ||||| 🎮 | ✅ 🌏 | |

src/features/rules/copilot-rule.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,52 @@ describe("CopilotRule", () => {
141141
});
142142
});
143143

144+
describe("getSettablePaths", () => {
145+
it("should return correct paths for root and nonRoot", () => {
146+
const paths = CopilotRule.getSettablePaths();
147+
148+
expect(paths.root).toEqual({
149+
relativeDirPath: ".github",
150+
relativeFilePath: "copilot-instructions.md",
151+
});
152+
153+
expect(paths.nonRoot).toEqual({
154+
relativeDirPath: ".github/instructions",
155+
});
156+
});
157+
158+
it("should have consistent paths structure", () => {
159+
const paths = CopilotRule.getSettablePaths();
160+
161+
expect(paths).toHaveProperty("root");
162+
expect(paths).toHaveProperty("nonRoot");
163+
expect(paths.root).toHaveProperty("relativeDirPath");
164+
expect(paths.root).toHaveProperty("relativeFilePath");
165+
expect(paths.nonRoot).toHaveProperty("relativeDirPath");
166+
});
167+
});
168+
169+
describe("getSettablePaths with global flag", () => {
170+
it("should return global-specific paths", () => {
171+
const paths = CopilotRule.getSettablePaths({ global: true });
172+
173+
expect(paths).toHaveProperty("root");
174+
expect(paths.root).toEqual({
175+
relativeDirPath: ".copilot",
176+
relativeFilePath: "copilot-instructions.md",
177+
});
178+
expect(paths).not.toHaveProperty("nonRoot");
179+
});
180+
181+
it("should have different paths than regular getSettablePaths", () => {
182+
const globalPaths = CopilotRule.getSettablePaths({ global: true });
183+
const regularPaths = CopilotRule.getSettablePaths();
184+
185+
expect(globalPaths.root.relativeDirPath).not.toBe(regularPaths.root.relativeDirPath);
186+
expect(globalPaths.root.relativeFilePath).toBe(regularPaths.root.relativeFilePath);
187+
});
188+
});
189+
144190
describe("toRulesyncRule", () => {
145191
it("should convert non-root CopilotRule to RulesyncRule", () => {
146192
const copilotRule = new CopilotRule({
@@ -309,6 +355,59 @@ describe("CopilotRule", () => {
309355
expect(copilotRule.isRoot()).toBe(true);
310356
});
311357

358+
it("should create root CopilotRule in global mode", () => {
359+
const rulesyncRule = new RulesyncRule({
360+
baseDir: testDir,
361+
relativeDirPath: "rules",
362+
relativeFilePath: "root.md",
363+
frontmatter: {
364+
targets: ["*"],
365+
root: true,
366+
description: "Root rule from rulesync",
367+
globs: ["**/*"],
368+
},
369+
body: "Root rulesync rule content",
370+
validate: true,
371+
});
372+
373+
const copilotRule = CopilotRule.fromRulesyncRule({
374+
baseDir: testDir,
375+
rulesyncRule,
376+
validate: true,
377+
global: true,
378+
});
379+
380+
expect(copilotRule.isRoot()).toBe(true);
381+
expect(copilotRule.getRelativeDirPath()).toBe(".copilot");
382+
expect(copilotRule.getRelativeFilePath()).toBe("copilot-instructions.md");
383+
});
384+
385+
it("should use regular paths when global=false for root rule", () => {
386+
const rulesyncRule = new RulesyncRule({
387+
baseDir: testDir,
388+
relativeDirPath: "rules",
389+
relativeFilePath: "root.md",
390+
frontmatter: {
391+
targets: ["*"],
392+
root: true,
393+
description: "Root rule from rulesync",
394+
globs: ["**/*"],
395+
},
396+
body: "Root rulesync rule content",
397+
validate: true,
398+
});
399+
400+
const copilotRule = CopilotRule.fromRulesyncRule({
401+
baseDir: testDir,
402+
rulesyncRule,
403+
validate: true,
404+
global: false,
405+
});
406+
407+
expect(copilotRule.getRelativeDirPath()).toBe(".github");
408+
expect(copilotRule.getRelativeFilePath()).toBe("copilot-instructions.md");
409+
});
410+
312411
it("should carry copilot-specific fields from RulesyncRule", () => {
313412
const rulesyncRule = new RulesyncRule({
314413
baseDir: testDir,
@@ -433,6 +532,58 @@ This is test rule content from file.`;
433532
expect(copilotRule.isRoot()).toBe(true);
434533
});
435534

535+
it("should load root file from .copilot/copilot-instructions.md when global=true", async () => {
536+
const copilotDir = join(testDir, ".copilot");
537+
await ensureDir(copilotDir);
538+
539+
const rootContent = "Global root content";
540+
const rootFilePath = join(copilotDir, "copilot-instructions.md");
541+
await writeFileContent(rootFilePath, rootContent);
542+
543+
const copilotRule = await CopilotRule.fromFile({
544+
baseDir: testDir,
545+
relativeFilePath: "copilot-instructions.md",
546+
global: true,
547+
});
548+
549+
expect(copilotRule.getRelativeDirPath()).toBe(".copilot");
550+
expect(copilotRule.getRelativeFilePath()).toBe("copilot-instructions.md");
551+
expect(copilotRule.getBody()).toBe(rootContent);
552+
expect(copilotRule.getFilePath()).toBe(join(testDir, ".copilot", "copilot-instructions.md"));
553+
});
554+
555+
it("should use global paths when global=true", async () => {
556+
const copilotDir = join(testDir, ".copilot");
557+
await ensureDir(copilotDir);
558+
const rootContent = "Global Mode Test";
559+
await writeFileContent(join(copilotDir, "copilot-instructions.md"), rootContent);
560+
561+
const copilotRule = await CopilotRule.fromFile({
562+
baseDir: testDir,
563+
relativeFilePath: "copilot-instructions.md",
564+
global: true,
565+
});
566+
567+
const globalPaths = CopilotRule.getSettablePaths({ global: true });
568+
expect(copilotRule.getRelativeDirPath()).toBe(globalPaths.root.relativeDirPath);
569+
expect(copilotRule.getRelativeFilePath()).toBe(globalPaths.root.relativeFilePath);
570+
});
571+
572+
it("should use regular paths when global=false", async () => {
573+
await ensureDir(join(testDir, ".github"));
574+
const rootContent = "Non-Global Mode Test";
575+
await writeFileContent(join(testDir, ".github", "copilot-instructions.md"), rootContent);
576+
577+
const copilotRule = await CopilotRule.fromFile({
578+
baseDir: testDir,
579+
relativeFilePath: "copilot-instructions.md",
580+
global: false,
581+
});
582+
583+
expect(copilotRule.getRelativeDirPath()).toBe(".github");
584+
expect(copilotRule.getRelativeFilePath()).toBe("copilot-instructions.md");
585+
});
586+
436587
it("should use default baseDir when not provided", async () => {
437588
const instructionsDir = join(testDir, ".github", "instructions");
438589
await ensureDir(instructionsDir);

src/features/rules/copilot-rule.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ToolRuleFromRulesyncRuleParams,
1515
ToolRuleParams,
1616
ToolRuleSettablePaths,
17+
ToolRuleSettablePathsGlobal,
1718
buildToolPath,
1819
} from "./tool-rule.js";
1920

@@ -40,23 +41,33 @@ export type CopilotRuleSettablePaths = Omit<ToolRuleSettablePaths, "root"> & {
4041
};
4142
};
4243

44+
export type CopilotRuleSettablePathsGlobal = ToolRuleSettablePathsGlobal;
45+
4346
export class CopilotRule extends ToolRule {
4447
private readonly frontmatter: CopilotRuleFrontmatter;
4548
private readonly body: string;
4649

4750
static getSettablePaths(
48-
_options: {
51+
options: {
4952
global?: boolean;
5053
excludeToolDir?: boolean;
5154
} = {},
52-
): CopilotRuleSettablePaths {
55+
): CopilotRuleSettablePaths | CopilotRuleSettablePathsGlobal {
56+
if (options.global) {
57+
return {
58+
root: {
59+
relativeDirPath: buildToolPath(".copilot", ".", options.excludeToolDir),
60+
relativeFilePath: "copilot-instructions.md",
61+
},
62+
};
63+
}
5364
return {
5465
root: {
55-
relativeDirPath: buildToolPath(".github", ".", _options.excludeToolDir),
66+
relativeDirPath: buildToolPath(".github", ".", options.excludeToolDir),
5667
relativeFilePath: "copilot-instructions.md",
5768
},
5869
nonRoot: {
59-
relativeDirPath: buildToolPath(".github", "instructions", _options.excludeToolDir),
70+
relativeDirPath: buildToolPath(".github", "instructions", options.excludeToolDir),
6071
},
6172
};
6273
}
@@ -120,9 +131,11 @@ export class CopilotRule extends ToolRule {
120131
baseDir = process.cwd(),
121132
rulesyncRule,
122133
validate = true,
134+
global = false,
123135
}: ToolRuleFromRulesyncRuleParams): CopilotRule {
124136
const rulesyncFrontmatter = rulesyncRule.getFrontmatter();
125137
const root = rulesyncFrontmatter.root;
138+
const paths = this.getSettablePaths({ global });
126139

127140
const copilotFrontmatter: CopilotRuleFrontmatter = {
128141
description: rulesyncFrontmatter.description,
@@ -139,13 +152,17 @@ export class CopilotRule extends ToolRule {
139152
baseDir: baseDir,
140153
frontmatter: copilotFrontmatter,
141154
body,
142-
relativeDirPath: this.getSettablePaths().root.relativeDirPath,
143-
relativeFilePath: this.getSettablePaths().root.relativeFilePath,
155+
relativeDirPath: paths.root.relativeDirPath,
156+
relativeFilePath: paths.root.relativeFilePath,
144157
validate,
145158
root,
146159
});
147160
}
148161

162+
if (!paths.nonRoot) {
163+
throw new Error(`nonRoot path is not set for ${rulesyncRule.getRelativeFilePath()}`);
164+
}
165+
149166
// Generate filename with .instructions.md extension
150167
const originalFileName = rulesyncRule.getRelativeFilePath();
151168
const nameWithoutExt = originalFileName.replace(/\.md$/, "");
@@ -155,7 +172,7 @@ export class CopilotRule extends ToolRule {
155172
baseDir: baseDir,
156173
frontmatter: copilotFrontmatter,
157174
body,
158-
relativeDirPath: this.getSettablePaths().nonRoot.relativeDirPath,
175+
relativeDirPath: paths.nonRoot.relativeDirPath,
159176
relativeFilePath: newFileName,
160177
validate,
161178
root,
@@ -166,30 +183,34 @@ export class CopilotRule extends ToolRule {
166183
baseDir = process.cwd(),
167184
relativeFilePath,
168185
validate = true,
186+
global = false,
169187
}: ToolRuleFromFileParams): Promise<CopilotRule> {
188+
const paths = this.getSettablePaths({ global });
170189
// Determine if this is a root file based on the file path
171-
const isRoot = relativeFilePath === "copilot-instructions.md";
172-
const relativePath = isRoot
173-
? join(
174-
this.getSettablePaths().root.relativeDirPath,
175-
this.getSettablePaths().root.relativeFilePath,
176-
)
177-
: join(this.getSettablePaths().nonRoot.relativeDirPath, relativeFilePath);
178-
const fileContent = await readFileContent(join(baseDir, relativePath));
190+
const isRoot = relativeFilePath === paths.root.relativeFilePath;
179191

180192
if (isRoot) {
193+
const relativePath = join(paths.root.relativeDirPath, paths.root.relativeFilePath);
194+
const fileContent = await readFileContent(join(baseDir, relativePath));
181195
// Root file: no frontmatter expected
182196
return new CopilotRule({
183197
baseDir: baseDir,
184-
relativeDirPath: this.getSettablePaths().root.relativeDirPath,
185-
relativeFilePath: this.getSettablePaths().root.relativeFilePath,
198+
relativeDirPath: paths.root.relativeDirPath,
199+
relativeFilePath: paths.root.relativeFilePath,
186200
frontmatter: {},
187201
body: fileContent.trim(),
188202
validate,
189203
root: isRoot,
190204
});
191205
}
192206

207+
if (!paths.nonRoot) {
208+
throw new Error(`nonRoot path is not set for ${relativeFilePath}`);
209+
}
210+
211+
const relativePath = join(paths.nonRoot.relativeDirPath, relativeFilePath);
212+
const fileContent = await readFileContent(join(baseDir, relativePath));
213+
193214
const { frontmatter, body: content } = parseFrontmatter(fileContent);
194215

195216
// Validate frontmatter using CopilotRuleFrontmatterSchema
@@ -202,7 +223,7 @@ export class CopilotRule extends ToolRule {
202223

203224
return new CopilotRule({
204225
baseDir: baseDir,
205-
relativeDirPath: this.getSettablePaths().nonRoot.relativeDirPath,
226+
relativeDirPath: paths.nonRoot.relativeDirPath,
206227
relativeFilePath: relativeFilePath.endsWith(".instructions.md")
207228
? relativeFilePath
208229
: relativeFilePath.replace(/\.md$/, ".instructions.md"),
@@ -217,8 +238,10 @@ export class CopilotRule extends ToolRule {
217238
baseDir = process.cwd(),
218239
relativeDirPath,
219240
relativeFilePath,
241+
global = false,
220242
}: ToolRuleForDeletionParams): CopilotRule {
221-
const isRoot = relativeFilePath === this.getSettablePaths().root.relativeFilePath;
243+
const paths = this.getSettablePaths({ global });
244+
const isRoot = relativeFilePath === paths.root.relativeFilePath;
222245

223246
return new CopilotRule({
224247
baseDir,

0 commit comments

Comments
 (0)