@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,99 @@
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 { writePiBundle } from "../src/targets/pi"
6
+ import type { PiBundle } from "../src/types/pi"
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("writePiBundle", () => {
18
+ test("writes prompts, skills, extensions, mcporter config, and AGENTS.md block", async () => {
19
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-writer-"))
20
+ const outputRoot = path.join(tempRoot, ".pi")
21
+
22
+ const bundle: PiBundle = {
23
+ prompts: [{ name: "workflows-plan", content: "Prompt content" }],
24
+ skillDirs: [
25
+ {
26
+ name: "skill-one",
27
+ sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
28
+ },
29
+ ],
30
+ generatedSkills: [{ name: "repo-research-analyst", content: "---\nname: repo-research-analyst\n---\n\nBody" }],
31
+ extensions: [{ name: "compound-engineering-compat.ts", content: "export default function () {}" }],
32
+ mcporterConfig: {
33
+ mcpServers: {
34
+ context7: { baseUrl: "https://mcp.context7.com/mcp" },
35
+ },
36
+ },
37
+ }
38
+
39
+ await writePiBundle(outputRoot, bundle)
40
+
41
+ expect(await exists(path.join(outputRoot, "prompts", "workflows-plan.md"))).toBe(true)
42
+ expect(await exists(path.join(outputRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
43
+ expect(await exists(path.join(outputRoot, "skills", "repo-research-analyst", "SKILL.md"))).toBe(true)
44
+ expect(await exists(path.join(outputRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true)
45
+ expect(await exists(path.join(outputRoot, "compound-engineering", "mcporter.json"))).toBe(true)
46
+
47
+ const agentsPath = path.join(outputRoot, "AGENTS.md")
48
+ const agentsContent = await fs.readFile(agentsPath, "utf8")
49
+ expect(agentsContent).toContain("BEGIN COMPOUND PI TOOL MAP")
50
+ expect(agentsContent).toContain("MCPorter")
51
+ })
52
+
53
+ test("writes to ~/.pi/agent style roots without nesting under .pi", async () => {
54
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agent-root-"))
55
+ const outputRoot = path.join(tempRoot, "agent")
56
+
57
+ const bundle: PiBundle = {
58
+ prompts: [{ name: "workflows-work", content: "Prompt content" }],
59
+ skillDirs: [],
60
+ generatedSkills: [],
61
+ extensions: [],
62
+ }
63
+
64
+ await writePiBundle(outputRoot, bundle)
65
+
66
+ expect(await exists(path.join(outputRoot, "prompts", "workflows-work.md"))).toBe(true)
67
+ expect(await exists(path.join(outputRoot, ".pi"))).toBe(false)
68
+ })
69
+
70
+ test("backs up existing mcporter config before overwriting", async () => {
71
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-backup-"))
72
+ const outputRoot = path.join(tempRoot, ".pi")
73
+ const configPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
74
+
75
+ await fs.mkdir(path.dirname(configPath), { recursive: true })
76
+ await fs.writeFile(configPath, JSON.stringify({ previous: true }, null, 2))
77
+
78
+ const bundle: PiBundle = {
79
+ prompts: [],
80
+ skillDirs: [],
81
+ generatedSkills: [],
82
+ extensions: [],
83
+ mcporterConfig: {
84
+ mcpServers: {
85
+ linear: { baseUrl: "https://mcp.linear.app/mcp" },
86
+ },
87
+ },
88
+ }
89
+
90
+ await writePiBundle(outputRoot, bundle)
91
+
92
+ const files = await fs.readdir(path.dirname(configPath))
93
+ const backupFileName = files.find((file) => file.startsWith("mcporter.json.bak."))
94
+ expect(backupFileName).toBeDefined()
95
+
96
+ const currentConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as { mcpServers: Record<string, unknown> }
97
+ expect(currentConfig.mcpServers.linear).toBeDefined()
98
+ })
99
+ })
@@ -0,0 +1,148 @@
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 { syncToCopilot } from "../src/sync/copilot"
6
+ import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
7
+
8
+ describe("syncToCopilot", () => {
9
+ test("symlinks skills to .github/skills/", async () => {
10
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-"))
11
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
12
+
13
+ const config: ClaudeHomeConfig = {
14
+ skills: [
15
+ {
16
+ name: "skill-one",
17
+ sourceDir: fixtureSkillDir,
18
+ skillPath: path.join(fixtureSkillDir, "SKILL.md"),
19
+ },
20
+ ],
21
+ mcpServers: {},
22
+ }
23
+
24
+ await syncToCopilot(config, tempRoot)
25
+
26
+ const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
27
+ const linkedStat = await fs.lstat(linkedSkillPath)
28
+ expect(linkedStat.isSymbolicLink()).toBe(true)
29
+ })
30
+
31
+ test("skips skills with invalid names", async () => {
32
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-"))
33
+
34
+ const config: ClaudeHomeConfig = {
35
+ skills: [
36
+ {
37
+ name: "../escape-attempt",
38
+ sourceDir: "/tmp/bad-skill",
39
+ skillPath: "/tmp/bad-skill/SKILL.md",
40
+ },
41
+ ],
42
+ mcpServers: {},
43
+ }
44
+
45
+ await syncToCopilot(config, tempRoot)
46
+
47
+ const skillsDir = path.join(tempRoot, "skills")
48
+ const entries = await fs.readdir(skillsDir).catch(() => [])
49
+ expect(entries).toHaveLength(0)
50
+ })
51
+
52
+ test("merges MCP config with existing file", async () => {
53
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-"))
54
+ const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
55
+
56
+ await fs.writeFile(
57
+ mcpPath,
58
+ JSON.stringify({
59
+ mcpServers: {
60
+ existing: { type: "local", command: "node", args: ["server.js"], tools: ["*"] },
61
+ },
62
+ }, null, 2),
63
+ )
64
+
65
+ const config: ClaudeHomeConfig = {
66
+ skills: [],
67
+ mcpServers: {
68
+ context7: { url: "https://mcp.context7.com/mcp" },
69
+ },
70
+ }
71
+
72
+ await syncToCopilot(config, tempRoot)
73
+
74
+ const merged = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
75
+ mcpServers: Record<string, { command?: string; url?: string; type: string }>
76
+ }
77
+
78
+ expect(merged.mcpServers.existing?.command).toBe("node")
79
+ expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
80
+ })
81
+
82
+ test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => {
83
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-env-"))
84
+
85
+ const config: ClaudeHomeConfig = {
86
+ skills: [],
87
+ mcpServers: {
88
+ server: {
89
+ command: "echo",
90
+ args: ["hello"],
91
+ env: { API_KEY: "secret", COPILOT_MCP_TOKEN: "already-prefixed" },
92
+ },
93
+ },
94
+ }
95
+
96
+ await syncToCopilot(config, tempRoot)
97
+
98
+ const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
99
+ const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
100
+ mcpServers: Record<string, { env?: Record<string, string> }>
101
+ }
102
+
103
+ expect(mcpConfig.mcpServers.server?.env).toEqual({
104
+ COPILOT_MCP_API_KEY: "secret",
105
+ COPILOT_MCP_TOKEN: "already-prefixed",
106
+ })
107
+ })
108
+
109
+ test("writes MCP config with restricted permissions", async () => {
110
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-perms-"))
111
+
112
+ const config: ClaudeHomeConfig = {
113
+ skills: [],
114
+ mcpServers: {
115
+ server: { command: "echo", args: ["hello"] },
116
+ },
117
+ }
118
+
119
+ await syncToCopilot(config, tempRoot)
120
+
121
+ const mcpPath = path.join(tempRoot, "copilot-mcp-config.json")
122
+ const stat = await fs.stat(mcpPath)
123
+ // Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms)
124
+ const perms = stat.mode & 0o777
125
+ expect(perms).toBe(0o600)
126
+ })
127
+
128
+ test("does not write MCP config when no MCP servers", async () => {
129
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-nomcp-"))
130
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
131
+
132
+ const config: ClaudeHomeConfig = {
133
+ skills: [
134
+ {
135
+ name: "skill-one",
136
+ sourceDir: fixtureSkillDir,
137
+ skillPath: path.join(fixtureSkillDir, "SKILL.md"),
138
+ },
139
+ ],
140
+ mcpServers: {},
141
+ }
142
+
143
+ await syncToCopilot(config, tempRoot)
144
+
145
+ const mcpExists = await fs.access(path.join(tempRoot, "copilot-mcp-config.json")).then(() => true).catch(() => false)
146
+ expect(mcpExists).toBe(false)
147
+ })
148
+ })
@@ -0,0 +1,57 @@
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 { syncToDroid } from "../src/sync/droid"
6
+ import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
7
+
8
+ describe("syncToDroid", () => {
9
+ test("symlinks skills to factory skills dir", async () => {
10
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-"))
11
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
12
+
13
+ const config: ClaudeHomeConfig = {
14
+ skills: [
15
+ {
16
+ name: "skill-one",
17
+ sourceDir: fixtureSkillDir,
18
+ skillPath: path.join(fixtureSkillDir, "SKILL.md"),
19
+ },
20
+ ],
21
+ mcpServers: {
22
+ context7: { url: "https://mcp.context7.com/mcp" },
23
+ },
24
+ }
25
+
26
+ await syncToDroid(config, tempRoot)
27
+
28
+ const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
29
+ const linkedStat = await fs.lstat(linkedSkillPath)
30
+ expect(linkedStat.isSymbolicLink()).toBe(true)
31
+
32
+ // Droid does not write MCP config
33
+ const mcpExists = await fs.access(path.join(tempRoot, "mcp.json")).then(() => true).catch(() => false)
34
+ expect(mcpExists).toBe(false)
35
+ })
36
+
37
+ test("skips skills with invalid names", async () => {
38
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-invalid-"))
39
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
40
+
41
+ const config: ClaudeHomeConfig = {
42
+ skills: [
43
+ {
44
+ name: "../escape",
45
+ sourceDir: fixtureSkillDir,
46
+ skillPath: path.join(fixtureSkillDir, "SKILL.md"),
47
+ },
48
+ ],
49
+ mcpServers: {},
50
+ }
51
+
52
+ await syncToDroid(config, tempRoot)
53
+
54
+ const entries = await fs.readdir(path.join(tempRoot, "skills"))
55
+ expect(entries).toHaveLength(0)
56
+ })
57
+ })
@@ -0,0 +1,68 @@
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 { syncToPi } from "../src/sync/pi"
6
+ import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
7
+
8
+ describe("syncToPi", () => {
9
+ test("symlinks skills and writes MCPorter config", async () => {
10
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-"))
11
+ const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
12
+
13
+ const config: ClaudeHomeConfig = {
14
+ skills: [
15
+ {
16
+ name: "skill-one",
17
+ sourceDir: fixtureSkillDir,
18
+ skillPath: path.join(fixtureSkillDir, "SKILL.md"),
19
+ },
20
+ ],
21
+ mcpServers: {
22
+ context7: { url: "https://mcp.context7.com/mcp" },
23
+ local: { command: "echo", args: ["hello"] },
24
+ },
25
+ }
26
+
27
+ await syncToPi(config, tempRoot)
28
+
29
+ const linkedSkillPath = path.join(tempRoot, "skills", "skill-one")
30
+ const linkedStat = await fs.lstat(linkedSkillPath)
31
+ expect(linkedStat.isSymbolicLink()).toBe(true)
32
+
33
+ const mcporterPath = path.join(tempRoot, "compound-engineering", "mcporter.json")
34
+ const mcporterConfig = JSON.parse(await fs.readFile(mcporterPath, "utf8")) as {
35
+ mcpServers: Record<string, { baseUrl?: string; command?: string }>
36
+ }
37
+
38
+ expect(mcporterConfig.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp")
39
+ expect(mcporterConfig.mcpServers.local?.command).toBe("echo")
40
+ })
41
+
42
+ test("merges existing MCPorter config", async () => {
43
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-pi-merge-"))
44
+ const mcporterPath = path.join(tempRoot, "compound-engineering", "mcporter.json")
45
+ await fs.mkdir(path.dirname(mcporterPath), { recursive: true })
46
+
47
+ await fs.writeFile(
48
+ mcporterPath,
49
+ JSON.stringify({ mcpServers: { existing: { baseUrl: "https://example.com/mcp" } } }, null, 2),
50
+ )
51
+
52
+ const config: ClaudeHomeConfig = {
53
+ skills: [],
54
+ mcpServers: {
55
+ context7: { url: "https://mcp.context7.com/mcp" },
56
+ },
57
+ }
58
+
59
+ await syncToPi(config, tempRoot)
60
+
61
+ const merged = JSON.parse(await fs.readFile(mcporterPath, "utf8")) as {
62
+ mcpServers: Record<string, { baseUrl?: string }>
63
+ }
64
+
65
+ expect(merged.mcpServers.existing?.baseUrl).toBe("https://example.com/mcp")
66
+ expect(merged.mcpServers.context7?.baseUrl).toBe("https://mcp.context7.com/mcp")
67
+ })
68
+ })
@@ -1,48 +0,0 @@
1
- import path from "path"
2
- import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
3
- import type { CursorBundle } from "../types/cursor"
4
-
5
- export async function writeCursorBundle(outputRoot: string, bundle: CursorBundle): Promise<void> {
6
- const paths = resolveCursorPaths(outputRoot)
7
- await ensureDir(paths.cursorDir)
8
-
9
- if (bundle.rules.length > 0) {
10
- const rulesDir = path.join(paths.cursorDir, "rules")
11
- for (const rule of bundle.rules) {
12
- await writeText(path.join(rulesDir, `${rule.name}.mdc`), rule.content + "\n")
13
- }
14
- }
15
-
16
- if (bundle.commands.length > 0) {
17
- const commandsDir = path.join(paths.cursorDir, "commands")
18
- for (const command of bundle.commands) {
19
- await writeText(path.join(commandsDir, `${command.name}.md`), command.content + "\n")
20
- }
21
- }
22
-
23
- if (bundle.skillDirs.length > 0) {
24
- const skillsDir = path.join(paths.cursorDir, "skills")
25
- for (const skill of bundle.skillDirs) {
26
- await copyDir(skill.sourceDir, path.join(skillsDir, skill.name))
27
- }
28
- }
29
-
30
- if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
31
- const mcpPath = path.join(paths.cursorDir, "mcp.json")
32
- const backupPath = await backupFile(mcpPath)
33
- if (backupPath) {
34
- console.log(`Backed up existing mcp.json to ${backupPath}`)
35
- }
36
- await writeJson(mcpPath, { mcpServers: bundle.mcpServers })
37
- }
38
- }
39
-
40
- function resolveCursorPaths(outputRoot: string) {
41
- const base = path.basename(outputRoot)
42
- // If already pointing at .cursor, write directly into it
43
- if (base === ".cursor") {
44
- return { cursorDir: outputRoot }
45
- }
46
- // Otherwise nest under .cursor
47
- return { cursorDir: path.join(outputRoot, ".cursor") }
48
- }
@@ -1,29 +0,0 @@
1
- export type CursorRule = {
2
- name: string
3
- content: string
4
- }
5
-
6
- export type CursorCommand = {
7
- name: string
8
- content: string
9
- }
10
-
11
- export type CursorSkillDir = {
12
- name: string
13
- sourceDir: string
14
- }
15
-
16
- export type CursorMcpServer = {
17
- command?: string
18
- args?: string[]
19
- env?: Record<string, string>
20
- url?: string
21
- headers?: Record<string, string>
22
- }
23
-
24
- export type CursorBundle = {
25
- rules: CursorRule[]
26
- commands: CursorCommand[]
27
- skillDirs: CursorSkillDir[]
28
- mcpServers?: Record<string, CursorMcpServer>
29
- }