Skip to content

Commit f5422c9

Browse files
authored
Merge pull request #1260 from dacgray/add-skills-for-junie
Adds junie skills and subagents
2 parents 5b0937a + 049d1e1 commit f5422c9

16 files changed

Lines changed: 1141 additions & 4 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,9 @@ bun.lock
213213
docs/.vitepress/dist
214214
docs/.vitepress/cache
215215

216+
**/.junie/skills/
217+
**/.junie/agents/
218+
216219
# Generated by Rulesync
217220
.rulesync/skills/.curated/
218221
**/AGENTS.md

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu
8080
| Qwen Code | qwencode ||| | | | | |
8181
| Kiro | kiro ||||||| |
8282
| Google Antigravity | antigravity || | || | ✅ 🌏 | |
83-
| JetBrains Junie | junie |||| | | | |
83+
| JetBrains Junie | junie |||| | | | |
8484
| AugmentCode | augmentcode ||| | | | | |
8585
| Windsurf | windsurf ||| | | | | |
8686
| Warp | warp || | | | | | |

docs/reference/supported-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
2020
| Qwen Code | qwencode ||| | | | | |
2121
| Kiro | kiro ||||||| |
2222
| Google Antigravity | antigravity || | || | ✅ 🌏 | |
23-
| JetBrains Junie | junie |||| | | | |
23+
| JetBrains Junie | junie |||| | | | |
2424
| AugmentCode | augmentcode ||| | | | | |
2525
| Windsurf | windsurf ||| | | | | |
2626
| Warp | warp || | | | | | |

skills/rulesync/supported-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
2020
| Qwen Code | qwencode ||| | | | | |
2121
| Kiro | kiro ||||||| |
2222
| Google Antigravity | antigravity || | || | ✅ 🌏 | |
23-
| JetBrains Junie | junie |||| | | | |
23+
| JetBrains Junie | junie |||| | | | |
2424
| AugmentCode | augmentcode ||| | | | | |
2525
| Windsurf | windsurf ||| | | | | |
2626
| Warp | warp || | | | | | |

src/cli/commands/gitignore.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ dist/`;
242242
**/.vscode/mcp.json
243243
**/.junie/guidelines.md
244244
**/.junie/mcp.json
245+
**/.junie/skills/
246+
**/.junie/agents/
245247
**/.kilocode/rules/
246248
**/.kilocode/skills/
247249
**/.kilocode/workflows/
@@ -371,6 +373,8 @@ rulesync.local.jsonc
371373
**/.vscode/mcp.json
372374
**/.junie/guidelines.md
373375
**/.junie/mcp.json
376+
**/.junie/skills/
377+
**/.junie/agents/
374378
**/.kilocode/rules/
375379
**/.kilocode/skills/
376380
**/.kilocode/workflows/

src/cli/commands/gitignore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ const RULESYNC_IGNORE_ENTRIES = [
7272
// Junie
7373
"**/.junie/guidelines.md",
7474
"**/.junie/mcp.json",
75+
"**/.junie/skills/",
76+
"**/.junie/agents/",
7577
// Kilo Code
7678
"**/.kilocode/rules/",
7779
"**/.kilocode/skills/",
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { join } from "node:path";
2+
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
import { SKILL_FILE_NAME } from "../../constants/general.js";
6+
import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
7+
import { setupTestDirectory } from "../../test-utils/test-directories.js";
8+
import { ensureDir, writeFileContent } from "../../utils/file.js";
9+
import { JunieSkill } from "./junie-skill.js";
10+
import { RulesyncSkill } from "./rulesync-skill.js";
11+
12+
describe("JunieSkill", () => {
13+
let testDir: string;
14+
let cleanup: () => Promise<void>;
15+
16+
beforeEach(async () => {
17+
const testSetup = await setupTestDirectory();
18+
testDir = testSetup.testDir;
19+
cleanup = testSetup.cleanup;
20+
vi.spyOn(process, "cwd").mockReturnValue(testDir);
21+
});
22+
23+
afterEach(async () => {
24+
await cleanup();
25+
vi.restoreAllMocks();
26+
});
27+
28+
describe("getSettablePaths", () => {
29+
it("should return .junie/skills as relativeDirPath", () => {
30+
const paths = JunieSkill.getSettablePaths();
31+
expect(paths.relativeDirPath).toBe(join(".junie", "skills"));
32+
});
33+
34+
it("should throw error when global mode is requested", () => {
35+
expect(() => JunieSkill.getSettablePaths({ global: true })).toThrow(
36+
"JunieSkill does not support global mode.",
37+
);
38+
});
39+
});
40+
41+
describe("constructor", () => {
42+
it("should create instance with valid content", () => {
43+
const skill = new JunieSkill({
44+
baseDir: testDir,
45+
relativeDirPath: join(".junie", "skills"),
46+
dirName: "test-skill",
47+
frontmatter: {
48+
name: "test-skill",
49+
description: "Test skill description",
50+
},
51+
body: "This is the body of the junie skill.",
52+
validate: true,
53+
});
54+
55+
expect(skill).toBeInstanceOf(JunieSkill);
56+
expect(skill.getBody()).toBe("This is the body of the junie skill.");
57+
expect(skill.getFrontmatter()).toEqual({
58+
name: "test-skill",
59+
description: "Test skill description",
60+
});
61+
});
62+
63+
it("should throw error when frontmatter name does not match dirName", () => {
64+
expect(
65+
() =>
66+
new JunieSkill({
67+
baseDir: testDir,
68+
relativeDirPath: join(".junie", "skills"),
69+
dirName: "test-skill",
70+
frontmatter: {
71+
name: "Different Name",
72+
description: "Test skill description",
73+
},
74+
body: "This is the body of the junie skill.",
75+
validate: true,
76+
}),
77+
).toThrow(/frontmatter name \(Different Name\) must match directory name \(test-skill\)/);
78+
});
79+
});
80+
81+
describe("fromDir", () => {
82+
it("should create instance from valid skill directory", async () => {
83+
const skillDir = join(testDir, ".junie", "skills", "test-skill");
84+
await ensureDir(skillDir);
85+
const skillContent = `---
86+
name: test-skill
87+
description: Test skill description
88+
---
89+
90+
This is the body of the junie skill.`;
91+
await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent);
92+
93+
const skill = await JunieSkill.fromDir({
94+
baseDir: testDir,
95+
dirName: "test-skill",
96+
});
97+
98+
expect(skill).toBeInstanceOf(JunieSkill);
99+
expect(skill.getBody()).toBe("This is the body of the junie skill.");
100+
expect(skill.getFrontmatter()).toEqual({
101+
name: "test-skill",
102+
description: "Test skill description",
103+
});
104+
});
105+
106+
it("should throw error when frontmatter name does not match dirName", async () => {
107+
const skillDir = join(testDir, ".junie", "skills", "test-skill");
108+
await ensureDir(skillDir);
109+
const skillContent = `---
110+
name: Different Name
111+
description: Test skill description
112+
---
113+
114+
This is the body of the junie skill.`;
115+
await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent);
116+
117+
await expect(
118+
JunieSkill.fromDir({
119+
baseDir: testDir,
120+
dirName: "test-skill",
121+
}),
122+
).rejects.toThrow(
123+
/Frontmatter name \(Different Name\) must match directory name \(test-skill\)/,
124+
);
125+
});
126+
127+
it("should throw error when SKILL.md not found", async () => {
128+
const skillDir = join(testDir, ".junie", "skills", "empty-skill");
129+
await ensureDir(skillDir);
130+
131+
await expect(
132+
JunieSkill.fromDir({
133+
baseDir: testDir,
134+
dirName: "empty-skill",
135+
}),
136+
).rejects.toThrow(/SKILL\.md not found/);
137+
});
138+
});
139+
140+
describe("fromRulesyncSkill", () => {
141+
it("should create instance from RulesyncSkill", () => {
142+
const rulesyncSkill = new RulesyncSkill({
143+
baseDir: testDir,
144+
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
145+
dirName: "some-other-name",
146+
frontmatter: {
147+
name: "test-skill",
148+
description: "Test skill description",
149+
},
150+
body: "Test body content",
151+
validate: true,
152+
});
153+
154+
const junieSkill = JunieSkill.fromRulesyncSkill({
155+
rulesyncSkill,
156+
validate: true,
157+
});
158+
159+
expect(junieSkill).toBeInstanceOf(JunieSkill);
160+
expect(junieSkill.getDirName()).toBe("test-skill");
161+
expect(junieSkill.getBody()).toBe("Test body content");
162+
expect(junieSkill.getFrontmatter()).toEqual({
163+
name: "test-skill",
164+
description: "Test skill description",
165+
});
166+
});
167+
});
168+
169+
describe("isTargetedByRulesyncSkill", () => {
170+
it("should return true when targets includes '*'", () => {
171+
const rulesyncSkill = new RulesyncSkill({
172+
baseDir: testDir,
173+
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
174+
dirName: "all-targets-skill",
175+
frontmatter: {
176+
name: "All Targets Skill",
177+
description: "Skill for all targets",
178+
targets: ["*"],
179+
},
180+
body: "Test body",
181+
validate: true,
182+
});
183+
184+
expect(JunieSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true);
185+
});
186+
187+
it("should return true when targets includes 'junie'", () => {
188+
const rulesyncSkill = new RulesyncSkill({
189+
baseDir: testDir,
190+
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
191+
dirName: "junie-skill",
192+
frontmatter: {
193+
name: "Junie Skill",
194+
description: "Skill for junie",
195+
targets: ["copilot", "junie"],
196+
},
197+
body: "Test body",
198+
validate: true,
199+
});
200+
201+
expect(JunieSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true);
202+
});
203+
204+
it("should return false when targets does not include 'junie'", () => {
205+
const rulesyncSkill = new RulesyncSkill({
206+
baseDir: testDir,
207+
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
208+
dirName: "claudecode-only-skill",
209+
frontmatter: {
210+
name: "ClaudeCode Only Skill",
211+
description: "Skill for claudecode only",
212+
targets: ["claudecode"],
213+
},
214+
body: "Test body",
215+
validate: true,
216+
});
217+
218+
expect(JunieSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false);
219+
});
220+
});
221+
222+
describe("toRulesyncSkill", () => {
223+
it("should convert to RulesyncSkill", () => {
224+
const skill = new JunieSkill({
225+
baseDir: testDir,
226+
relativeDirPath: join(".junie", "skills"),
227+
dirName: "test-skill",
228+
frontmatter: {
229+
name: "test-skill",
230+
description: "Test description",
231+
},
232+
body: "Test body",
233+
validate: true,
234+
});
235+
236+
const rulesyncSkill = skill.toRulesyncSkill();
237+
238+
expect(rulesyncSkill).toBeInstanceOf(RulesyncSkill);
239+
expect(rulesyncSkill.getFrontmatter()).toEqual({
240+
name: "test-skill",
241+
description: "Test description",
242+
targets: ["*"],
243+
});
244+
expect(rulesyncSkill.getBody()).toBe("Test body");
245+
});
246+
});
247+
248+
describe("forDeletion", () => {
249+
it("should create minimal instance for deletion", () => {
250+
const skill = JunieSkill.forDeletion({
251+
dirName: "cleanup",
252+
relativeDirPath: join(".junie", "skills"),
253+
});
254+
255+
expect(skill.getDirName()).toBe("cleanup");
256+
expect(skill.getRelativeDirPath()).toBe(join(".junie", "skills"));
257+
expect(skill.getGlobal()).toBe(false);
258+
});
259+
260+
it("should use process.cwd() as default baseDir", () => {
261+
const skill = JunieSkill.forDeletion({
262+
dirName: "cleanup",
263+
relativeDirPath: join(".junie", "skills"),
264+
});
265+
266+
expect(skill).toBeInstanceOf(JunieSkill);
267+
expect(skill.getBaseDir()).toBe(testDir);
268+
});
269+
270+
it("should create instance with empty frontmatter for deletion", () => {
271+
const skill = JunieSkill.forDeletion({
272+
dirName: "to-delete",
273+
relativeDirPath: join(".junie", "skills"),
274+
});
275+
276+
expect(skill.getFrontmatter()).toEqual({
277+
name: "",
278+
description: "",
279+
});
280+
expect(skill.getBody()).toBe("");
281+
});
282+
});
283+
});

0 commit comments

Comments
 (0)