@every-env/compound-plugin 0.5.2 → 0.8.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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.cursor-plugin/marketplace.json +25 -0
  3. package/CHANGELOG.md +47 -0
  4. package/README.md +29 -6
  5. package/bun.lock +1 -0
  6. package/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md +117 -0
  7. package/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md +30 -0
  8. package/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md +328 -0
  9. package/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md +370 -0
  10. package/docs/specs/copilot.md +122 -0
  11. package/docs/specs/gemini.md +122 -0
  12. package/package.json +1 -1
  13. package/plugins/coding-tutor/.cursor-plugin/plugin.json +21 -0
  14. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  15. package/plugins/compound-engineering/.cursor-plugin/plugin.json +31 -0
  16. package/plugins/compound-engineering/.mcp.json +8 -0
  17. package/plugins/compound-engineering/CHANGELOG.md +27 -0
  18. package/plugins/compound-engineering/commands/lfg.md +3 -3
  19. package/plugins/compound-engineering/commands/slfg.md +2 -2
  20. package/plugins/compound-engineering/commands/workflows/plan.md +18 -1
  21. package/plugins/compound-engineering/commands/workflows/work.md +8 -1
  22. package/src/commands/convert.ts +14 -25
  23. package/src/commands/install.ts +27 -25
  24. package/src/commands/sync.ts +44 -21
  25. package/src/converters/{claude-to-cursor.ts → claude-to-copilot.ts} +93 -49
  26. package/src/converters/claude-to-gemini.ts +193 -0
  27. package/src/converters/claude-to-opencode.ts +16 -0
  28. package/src/converters/claude-to-pi.ts +205 -0
  29. package/src/sync/copilot.ts +100 -0
  30. package/src/sync/droid.ts +21 -0
  31. package/src/sync/pi.ts +88 -0
  32. package/src/targets/copilot.ts +48 -0
  33. package/src/targets/gemini.ts +68 -0
  34. package/src/targets/index.ts +25 -7
  35. package/src/targets/pi.ts +131 -0
  36. package/src/templates/pi/compat-extension.ts +452 -0
  37. package/src/types/copilot.ts +31 -0
  38. package/src/types/gemini.ts +29 -0
  39. package/src/types/pi.ts +40 -0
  40. package/src/utils/frontmatter.ts +1 -1
  41. package/src/utils/resolve-home.ts +17 -0
  42. package/tests/cli.test.ts +76 -0
  43. package/tests/converter.test.ts +29 -0
  44. package/tests/copilot-converter.test.ts +467 -0
  45. package/tests/copilot-writer.test.ts +189 -0
  46. package/tests/gemini-converter.test.ts +373 -0
  47. package/tests/gemini-writer.test.ts +181 -0
  48. package/tests/pi-converter.test.ts +116 -0
  49. package/tests/pi-writer.test.ts +99 -0
  50. package/tests/sync-copilot.test.ts +148 -0
  51. package/tests/sync-droid.test.ts +57 -0
  52. package/tests/sync-pi.test.ts +68 -0
  53. package/src/targets/cursor.ts +0 -48
  54. package/src/types/cursor.ts +0 -29
  55. package/tests/cursor-converter.test.ts +0 -347
  56. package/tests/cursor-writer.test.ts +0 -137
@@ -0,0 +1,467 @@
1
+ import { describe, expect, test, spyOn } from "bun:test"
2
+ import { convertClaudeToCopilot, transformContentForCopilot } from "../src/converters/claude-to-copilot"
3
+ import { parseFrontmatter } from "../src/utils/frontmatter"
4
+ import type { ClaudePlugin } from "../src/types/claude"
5
+
6
+ const fixturePlugin: ClaudePlugin = {
7
+ root: "/tmp/plugin",
8
+ manifest: { name: "fixture", version: "1.0.0" },
9
+ agents: [
10
+ {
11
+ name: "Security Reviewer",
12
+ description: "Security-focused code review agent",
13
+ capabilities: ["Threat modeling", "OWASP"],
14
+ model: "claude-sonnet-4-20250514",
15
+ body: "Focus on vulnerabilities.",
16
+ sourcePath: "/tmp/plugin/agents/security-reviewer.md",
17
+ },
18
+ ],
19
+ commands: [
20
+ {
21
+ name: "workflows:plan",
22
+ description: "Planning command",
23
+ argumentHint: "[FOCUS]",
24
+ model: "inherit",
25
+ allowedTools: ["Read"],
26
+ body: "Plan the work.",
27
+ sourcePath: "/tmp/plugin/commands/workflows/plan.md",
28
+ },
29
+ ],
30
+ skills: [
31
+ {
32
+ name: "existing-skill",
33
+ description: "Existing skill",
34
+ sourceDir: "/tmp/plugin/skills/existing-skill",
35
+ skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
36
+ },
37
+ ],
38
+ hooks: undefined,
39
+ mcpServers: undefined,
40
+ }
41
+
42
+ const defaultOptions = {
43
+ agentMode: "subagent" as const,
44
+ inferTemperature: false,
45
+ permissions: "none" as const,
46
+ }
47
+
48
+ describe("convertClaudeToCopilot", () => {
49
+ test("converts agents to .agent.md with Copilot frontmatter", () => {
50
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
51
+
52
+ expect(bundle.agents).toHaveLength(1)
53
+ const agent = bundle.agents[0]
54
+ expect(agent.name).toBe("security-reviewer")
55
+
56
+ const parsed = parseFrontmatter(agent.content)
57
+ expect(parsed.data.description).toBe("Security-focused code review agent")
58
+ expect(parsed.data.tools).toEqual(["*"])
59
+ expect(parsed.data.infer).toBe(true)
60
+ expect(parsed.body).toContain("Capabilities")
61
+ expect(parsed.body).toContain("Threat modeling")
62
+ expect(parsed.body).toContain("Focus on vulnerabilities.")
63
+ })
64
+
65
+ test("agent description is required, fallback generated if missing", () => {
66
+ const plugin: ClaudePlugin = {
67
+ ...fixturePlugin,
68
+ agents: [
69
+ {
70
+ name: "basic-agent",
71
+ body: "Do things.",
72
+ sourcePath: "/tmp/plugin/agents/basic.md",
73
+ },
74
+ ],
75
+ }
76
+
77
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
78
+ const parsed = parseFrontmatter(bundle.agents[0].content)
79
+ expect(parsed.data.description).toBe("Converted from Claude agent basic-agent")
80
+ })
81
+
82
+ test("agent with empty body gets default body", () => {
83
+ const plugin: ClaudePlugin = {
84
+ ...fixturePlugin,
85
+ agents: [
86
+ {
87
+ name: "empty-agent",
88
+ description: "Empty agent",
89
+ body: "",
90
+ sourcePath: "/tmp/plugin/agents/empty.md",
91
+ },
92
+ ],
93
+ }
94
+
95
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
96
+ const parsed = parseFrontmatter(bundle.agents[0].content)
97
+ expect(parsed.body).toContain("Instructions converted from the empty-agent agent.")
98
+ })
99
+
100
+ test("agent capabilities are prepended to body", () => {
101
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
102
+ const parsed = parseFrontmatter(bundle.agents[0].content)
103
+ expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/)
104
+ })
105
+
106
+ test("agent model field is passed through", () => {
107
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
108
+ const parsed = parseFrontmatter(bundle.agents[0].content)
109
+ expect(parsed.data.model).toBe("claude-sonnet-4-20250514")
110
+ })
111
+
112
+ test("agent without model omits model field", () => {
113
+ const plugin: ClaudePlugin = {
114
+ ...fixturePlugin,
115
+ agents: [
116
+ {
117
+ name: "no-model",
118
+ description: "No model agent",
119
+ body: "Content.",
120
+ sourcePath: "/tmp/plugin/agents/no-model.md",
121
+ },
122
+ ],
123
+ }
124
+
125
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
126
+ const parsed = parseFrontmatter(bundle.agents[0].content)
127
+ expect(parsed.data.model).toBeUndefined()
128
+ })
129
+
130
+ test("agent tools defaults to [*]", () => {
131
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
132
+ const parsed = parseFrontmatter(bundle.agents[0].content)
133
+ expect(parsed.data.tools).toEqual(["*"])
134
+ })
135
+
136
+ test("agent infer defaults to true", () => {
137
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
138
+ const parsed = parseFrontmatter(bundle.agents[0].content)
139
+ expect(parsed.data.infer).toBe(true)
140
+ })
141
+
142
+ test("warns when agent body exceeds 30k characters", () => {
143
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
144
+
145
+ const plugin: ClaudePlugin = {
146
+ ...fixturePlugin,
147
+ agents: [
148
+ {
149
+ name: "large-agent",
150
+ description: "Large agent",
151
+ body: "x".repeat(31_000),
152
+ sourcePath: "/tmp/plugin/agents/large.md",
153
+ },
154
+ ],
155
+ commands: [],
156
+ skills: [],
157
+ }
158
+
159
+ convertClaudeToCopilot(plugin, defaultOptions)
160
+ expect(warnSpy).toHaveBeenCalledWith(
161
+ expect.stringContaining("exceeds 30000 characters"),
162
+ )
163
+
164
+ warnSpy.mockRestore()
165
+ })
166
+
167
+ test("converts commands to skills with SKILL.md format", () => {
168
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
169
+
170
+ expect(bundle.generatedSkills).toHaveLength(1)
171
+ const skill = bundle.generatedSkills[0]
172
+ expect(skill.name).toBe("workflows-plan")
173
+
174
+ const parsed = parseFrontmatter(skill.content)
175
+ expect(parsed.data.name).toBe("workflows-plan")
176
+ expect(parsed.data.description).toBe("Planning command")
177
+ expect(parsed.body).toContain("Plan the work.")
178
+ })
179
+
180
+ test("preserves namespaced command names with hyphens", () => {
181
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
182
+ expect(bundle.generatedSkills[0].name).toBe("workflows-plan")
183
+ })
184
+
185
+ test("command name collision after normalization is deduplicated", () => {
186
+ const plugin: ClaudePlugin = {
187
+ ...fixturePlugin,
188
+ commands: [
189
+ {
190
+ name: "workflows:plan",
191
+ description: "Workflow plan",
192
+ body: "Plan body.",
193
+ sourcePath: "/tmp/plugin/commands/workflows/plan.md",
194
+ },
195
+ {
196
+ name: "workflows:plan",
197
+ description: "Duplicate plan",
198
+ body: "Duplicate body.",
199
+ sourcePath: "/tmp/plugin/commands/workflows/plan2.md",
200
+ },
201
+ ],
202
+ agents: [],
203
+ skills: [],
204
+ }
205
+
206
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
207
+ const names = bundle.generatedSkills.map((s) => s.name)
208
+ expect(names).toEqual(["workflows-plan", "workflows-plan-2"])
209
+ })
210
+
211
+ test("namespaced and non-namespaced commands produce distinct names", () => {
212
+ const plugin: ClaudePlugin = {
213
+ ...fixturePlugin,
214
+ commands: [
215
+ {
216
+ name: "workflows:plan",
217
+ description: "Workflow plan",
218
+ body: "Plan body.",
219
+ sourcePath: "/tmp/plugin/commands/workflows/plan.md",
220
+ },
221
+ {
222
+ name: "plan",
223
+ description: "Top-level plan",
224
+ body: "Top plan body.",
225
+ sourcePath: "/tmp/plugin/commands/plan.md",
226
+ },
227
+ ],
228
+ agents: [],
229
+ skills: [],
230
+ }
231
+
232
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
233
+ const names = bundle.generatedSkills.map((s) => s.name)
234
+ expect(names).toEqual(["workflows-plan", "plan"])
235
+ })
236
+
237
+ test("command allowedTools is silently dropped", () => {
238
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
239
+ const skill = bundle.generatedSkills[0]
240
+ expect(skill.content).not.toContain("allowedTools")
241
+ expect(skill.content).not.toContain("allowed-tools")
242
+ })
243
+
244
+ test("command with argument-hint gets Arguments section", () => {
245
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
246
+ const skill = bundle.generatedSkills[0]
247
+ expect(skill.content).toContain("## Arguments")
248
+ expect(skill.content).toContain("[FOCUS]")
249
+ })
250
+
251
+ test("passes through skill directories", () => {
252
+ const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions)
253
+
254
+ expect(bundle.skillDirs).toHaveLength(1)
255
+ expect(bundle.skillDirs[0].name).toBe("existing-skill")
256
+ expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill")
257
+ })
258
+
259
+ test("skill and generated skill name collision is deduplicated", () => {
260
+ const plugin: ClaudePlugin = {
261
+ ...fixturePlugin,
262
+ commands: [
263
+ {
264
+ name: "existing-skill",
265
+ description: "Colliding command",
266
+ body: "This collides with skill name.",
267
+ sourcePath: "/tmp/plugin/commands/existing-skill.md",
268
+ },
269
+ ],
270
+ agents: [],
271
+ }
272
+
273
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
274
+ // The command should get deduplicated since the skill name is reserved
275
+ expect(bundle.generatedSkills[0].name).toBe("existing-skill-2")
276
+ expect(bundle.skillDirs[0].name).toBe("existing-skill")
277
+ })
278
+
279
+ test("converts MCP servers with COPILOT_MCP_ prefix", () => {
280
+ const plugin: ClaudePlugin = {
281
+ ...fixturePlugin,
282
+ agents: [],
283
+ commands: [],
284
+ skills: [],
285
+ mcpServers: {
286
+ playwright: {
287
+ command: "npx",
288
+ args: ["-y", "@anthropic/mcp-playwright"],
289
+ env: { DISPLAY: ":0", API_KEY: "secret" },
290
+ },
291
+ },
292
+ }
293
+
294
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
295
+ expect(bundle.mcpConfig).toBeDefined()
296
+ expect(bundle.mcpConfig!.playwright.type).toBe("local")
297
+ expect(bundle.mcpConfig!.playwright.command).toBe("npx")
298
+ expect(bundle.mcpConfig!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"])
299
+ expect(bundle.mcpConfig!.playwright.tools).toEqual(["*"])
300
+ expect(bundle.mcpConfig!.playwright.env).toEqual({
301
+ COPILOT_MCP_DISPLAY: ":0",
302
+ COPILOT_MCP_API_KEY: "secret",
303
+ })
304
+ })
305
+
306
+ test("MCP env vars already prefixed are not double-prefixed", () => {
307
+ const plugin: ClaudePlugin = {
308
+ ...fixturePlugin,
309
+ agents: [],
310
+ commands: [],
311
+ skills: [],
312
+ mcpServers: {
313
+ server: {
314
+ command: "node",
315
+ args: ["server.js"],
316
+ env: { COPILOT_MCP_TOKEN: "abc" },
317
+ },
318
+ },
319
+ }
320
+
321
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
322
+ expect(bundle.mcpConfig!.server.env).toEqual({ COPILOT_MCP_TOKEN: "abc" })
323
+ })
324
+
325
+ test("MCP servers get type field (local vs sse)", () => {
326
+ const plugin: ClaudePlugin = {
327
+ ...fixturePlugin,
328
+ agents: [],
329
+ commands: [],
330
+ skills: [],
331
+ mcpServers: {
332
+ local: { command: "npx", args: ["server"] },
333
+ remote: { url: "https://mcp.example.com/sse" },
334
+ },
335
+ }
336
+
337
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
338
+ expect(bundle.mcpConfig!.local.type).toBe("local")
339
+ expect(bundle.mcpConfig!.remote.type).toBe("sse")
340
+ })
341
+
342
+ test("MCP headers pass through for remote servers", () => {
343
+ const plugin: ClaudePlugin = {
344
+ ...fixturePlugin,
345
+ agents: [],
346
+ commands: [],
347
+ skills: [],
348
+ mcpServers: {
349
+ remote: {
350
+ url: "https://mcp.example.com/sse",
351
+ headers: { Authorization: "Bearer token" },
352
+ },
353
+ },
354
+ }
355
+
356
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
357
+ expect(bundle.mcpConfig!.remote.url).toBe("https://mcp.example.com/sse")
358
+ expect(bundle.mcpConfig!.remote.headers).toEqual({ Authorization: "Bearer token" })
359
+ })
360
+
361
+ test("warns when hooks are present", () => {
362
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
363
+
364
+ const plugin: ClaudePlugin = {
365
+ ...fixturePlugin,
366
+ agents: [],
367
+ commands: [],
368
+ skills: [],
369
+ hooks: {
370
+ hooks: {
371
+ PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }],
372
+ },
373
+ },
374
+ }
375
+
376
+ convertClaudeToCopilot(plugin, defaultOptions)
377
+ expect(warnSpy).toHaveBeenCalledWith(
378
+ "Warning: Copilot does not support hooks. Hooks were skipped during conversion.",
379
+ )
380
+
381
+ warnSpy.mockRestore()
382
+ })
383
+
384
+ test("no warning when hooks are absent", () => {
385
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
386
+
387
+ convertClaudeToCopilot(fixturePlugin, defaultOptions)
388
+ expect(warnSpy).not.toHaveBeenCalled()
389
+
390
+ warnSpy.mockRestore()
391
+ })
392
+
393
+ test("plugin with zero agents produces empty agents array", () => {
394
+ const plugin: ClaudePlugin = {
395
+ ...fixturePlugin,
396
+ agents: [],
397
+ }
398
+
399
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
400
+ expect(bundle.agents).toHaveLength(0)
401
+ })
402
+
403
+ test("plugin with only skills works", () => {
404
+ const plugin: ClaudePlugin = {
405
+ ...fixturePlugin,
406
+ agents: [],
407
+ commands: [],
408
+ }
409
+
410
+ const bundle = convertClaudeToCopilot(plugin, defaultOptions)
411
+ expect(bundle.agents).toHaveLength(0)
412
+ expect(bundle.generatedSkills).toHaveLength(0)
413
+ expect(bundle.skillDirs).toHaveLength(1)
414
+ })
415
+ })
416
+
417
+ describe("transformContentForCopilot", () => {
418
+ test("rewrites .claude/ paths to .github/", () => {
419
+ const input = "Read `.claude/compound-engineering.local.md` for config."
420
+ const result = transformContentForCopilot(input)
421
+ expect(result).toContain(".github/compound-engineering.local.md")
422
+ expect(result).not.toContain(".claude/")
423
+ })
424
+
425
+ test("rewrites ~/.claude/ paths to ~/.copilot/", () => {
426
+ const input = "Global config at ~/.claude/settings.json"
427
+ const result = transformContentForCopilot(input)
428
+ expect(result).toContain("~/.copilot/settings.json")
429
+ expect(result).not.toContain("~/.claude/")
430
+ })
431
+
432
+ test("transforms Task agent calls to skill references", () => {
433
+ const input = `Run agents:
434
+
435
+ - Task repo-research-analyst(feature_description)
436
+ - Task learnings-researcher(feature_description)
437
+
438
+ Task best-practices-researcher(topic)`
439
+
440
+ const result = transformContentForCopilot(input)
441
+ expect(result).toContain("Use the repo-research-analyst skill to: feature_description")
442
+ expect(result).toContain("Use the learnings-researcher skill to: feature_description")
443
+ expect(result).toContain("Use the best-practices-researcher skill to: topic")
444
+ expect(result).not.toContain("Task repo-research-analyst(")
445
+ })
446
+
447
+ test("replaces colons with hyphens in slash commands", () => {
448
+ const input = `1. Run /deepen-plan to enhance
449
+ 2. Start /workflows:work to implement
450
+ 3. File at /tmp/output.md`
451
+
452
+ const result = transformContentForCopilot(input)
453
+ expect(result).toContain("/deepen-plan")
454
+ expect(result).toContain("/workflows-work")
455
+ expect(result).not.toContain("/workflows:work")
456
+ // File paths preserved
457
+ expect(result).toContain("/tmp/output.md")
458
+ })
459
+
460
+ test("transforms @agent references to agent references", () => {
461
+ const input = "Have @security-sentinel and @dhh-rails-reviewer check the code."
462
+ const result = transformContentForCopilot(input)
463
+ expect(result).toContain("the security-sentinel agent")
464
+ expect(result).toContain("the dhh-rails-reviewer agent")
465
+ expect(result).not.toContain("@security-sentinel")
466
+ })
467
+ })
@@ -0,0 +1,189 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { promises as fs } from "fs"
3
+ import path from "path"
4
+ import os from "os"
5
+ import { writeCopilotBundle } from "../src/targets/copilot"
6
+ import type { CopilotBundle } from "../src/types/copilot"
7
+
8
+ async function exists(filePath: string): Promise<boolean> {
9
+ try {
10
+ await fs.access(filePath)
11
+ return true
12
+ } catch {
13
+ return false
14
+ }
15
+ }
16
+
17
+ describe("writeCopilotBundle", () => {
18
+ test("writes agents, generated skills, copied skills, and MCP config", async () => {
19
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-test-"))
20
+ const bundle: CopilotBundle = {
21
+ agents: [
22
+ {
23
+ name: "security-reviewer",
24
+ content: "---\ndescription: Security\ntools:\n - '*'\ninfer: true\n---\n\nReview code.",
25
+ },
26
+ ],
27
+ generatedSkills: [
28
+ {
29
+ name: "plan",
30
+ content: "---\nname: plan\ndescription: Planning\n---\n\nPlan the work.",
31
+ },
32
+ ],
33
+ skillDirs: [
34
+ {
35
+ name: "skill-one",
36
+ sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
37
+ },
38
+ ],
39
+ mcpConfig: {
40
+ playwright: {
41
+ type: "local",
42
+ command: "npx",
43
+ args: ["-y", "@anthropic/mcp-playwright"],
44
+ tools: ["*"],
45
+ },
46
+ },
47
+ }
48
+
49
+ await writeCopilotBundle(tempRoot, bundle)
50
+
51
+ expect(await exists(path.join(tempRoot, ".github", "agents", "security-reviewer.agent.md"))).toBe(true)
52
+ expect(await exists(path.join(tempRoot, ".github", "skills", "plan", "SKILL.md"))).toBe(true)
53
+ expect(await exists(path.join(tempRoot, ".github", "skills", "skill-one", "SKILL.md"))).toBe(true)
54
+ expect(await exists(path.join(tempRoot, ".github", "copilot-mcp-config.json"))).toBe(true)
55
+
56
+ const agentContent = await fs.readFile(
57
+ path.join(tempRoot, ".github", "agents", "security-reviewer.agent.md"),
58
+ "utf8",
59
+ )
60
+ expect(agentContent).toContain("Review code.")
61
+
62
+ const skillContent = await fs.readFile(
63
+ path.join(tempRoot, ".github", "skills", "plan", "SKILL.md"),
64
+ "utf8",
65
+ )
66
+ expect(skillContent).toContain("Plan the work.")
67
+
68
+ const mcpContent = JSON.parse(
69
+ await fs.readFile(path.join(tempRoot, ".github", "copilot-mcp-config.json"), "utf8"),
70
+ )
71
+ expect(mcpContent.mcpServers.playwright.command).toBe("npx")
72
+ })
73
+
74
+ test("agents use .agent.md file extension", async () => {
75
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-ext-"))
76
+ const bundle: CopilotBundle = {
77
+ agents: [{ name: "test-agent", content: "Agent content" }],
78
+ generatedSkills: [],
79
+ skillDirs: [],
80
+ }
81
+
82
+ await writeCopilotBundle(tempRoot, bundle)
83
+
84
+ expect(await exists(path.join(tempRoot, ".github", "agents", "test-agent.agent.md"))).toBe(true)
85
+ // Should NOT create a plain .md file
86
+ expect(await exists(path.join(tempRoot, ".github", "agents", "test-agent.md"))).toBe(false)
87
+ })
88
+
89
+ test("writes directly into .github output root without double-nesting", async () => {
90
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-home-"))
91
+ const githubRoot = path.join(tempRoot, ".github")
92
+ const bundle: CopilotBundle = {
93
+ agents: [{ name: "reviewer", content: "Reviewer agent content" }],
94
+ generatedSkills: [{ name: "plan", content: "Plan content" }],
95
+ skillDirs: [],
96
+ }
97
+
98
+ await writeCopilotBundle(githubRoot, bundle)
99
+
100
+ expect(await exists(path.join(githubRoot, "agents", "reviewer.agent.md"))).toBe(true)
101
+ expect(await exists(path.join(githubRoot, "skills", "plan", "SKILL.md"))).toBe(true)
102
+ // Should NOT double-nest under .github/.github
103
+ expect(await exists(path.join(githubRoot, ".github"))).toBe(false)
104
+ })
105
+
106
+ test("handles empty bundles gracefully", async () => {
107
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-empty-"))
108
+ const bundle: CopilotBundle = {
109
+ agents: [],
110
+ generatedSkills: [],
111
+ skillDirs: [],
112
+ }
113
+
114
+ await writeCopilotBundle(tempRoot, bundle)
115
+ expect(await exists(tempRoot)).toBe(true)
116
+ })
117
+
118
+ test("writes multiple agents as separate .agent.md files", async () => {
119
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-multi-"))
120
+ const githubRoot = path.join(tempRoot, ".github")
121
+ const bundle: CopilotBundle = {
122
+ agents: [
123
+ { name: "security-sentinel", content: "Security rules" },
124
+ { name: "performance-oracle", content: "Performance rules" },
125
+ { name: "code-simplicity-reviewer", content: "Simplicity rules" },
126
+ ],
127
+ generatedSkills: [],
128
+ skillDirs: [],
129
+ }
130
+
131
+ await writeCopilotBundle(githubRoot, bundle)
132
+
133
+ expect(await exists(path.join(githubRoot, "agents", "security-sentinel.agent.md"))).toBe(true)
134
+ expect(await exists(path.join(githubRoot, "agents", "performance-oracle.agent.md"))).toBe(true)
135
+ expect(await exists(path.join(githubRoot, "agents", "code-simplicity-reviewer.agent.md"))).toBe(true)
136
+ })
137
+
138
+ test("backs up existing copilot-mcp-config.json before overwriting", async () => {
139
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-backup-"))
140
+ const githubRoot = path.join(tempRoot, ".github")
141
+ await fs.mkdir(githubRoot, { recursive: true })
142
+
143
+ // Write an existing config
144
+ const mcpPath = path.join(githubRoot, "copilot-mcp-config.json")
145
+ await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { type: "local", command: "old-cmd", tools: ["*"] } } }))
146
+
147
+ const bundle: CopilotBundle = {
148
+ agents: [],
149
+ generatedSkills: [],
150
+ skillDirs: [],
151
+ mcpConfig: {
152
+ newServer: { type: "local", command: "new-cmd", tools: ["*"] },
153
+ },
154
+ }
155
+
156
+ await writeCopilotBundle(githubRoot, bundle)
157
+
158
+ // New config should have the new content
159
+ const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8"))
160
+ expect(newContent.mcpServers.newServer.command).toBe("new-cmd")
161
+
162
+ // A backup file should exist
163
+ const files = await fs.readdir(githubRoot)
164
+ const backupFiles = files.filter((f) => f.startsWith("copilot-mcp-config.json.bak."))
165
+ expect(backupFiles.length).toBeGreaterThanOrEqual(1)
166
+ })
167
+
168
+ test("creates skill directories with SKILL.md", async () => {
169
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-genskill-"))
170
+ const bundle: CopilotBundle = {
171
+ agents: [],
172
+ generatedSkills: [
173
+ {
174
+ name: "deploy",
175
+ content: "---\nname: deploy\ndescription: Deploy skill\n---\n\nDeploy steps.",
176
+ },
177
+ ],
178
+ skillDirs: [],
179
+ }
180
+
181
+ await writeCopilotBundle(tempRoot, bundle)
182
+
183
+ const skillPath = path.join(tempRoot, ".github", "skills", "deploy", "SKILL.md")
184
+ expect(await exists(skillPath)).toBe(true)
185
+
186
+ const content = await fs.readFile(skillPath, "utf8")
187
+ expect(content).toContain("Deploy steps.")
188
+ })
189
+ })