@every-env/compound-plugin 0.3.0 → 0.5.1
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/{plugins/compound-engineering → .claude}/commands/release-docs.md +0 -1
- package/.claude-plugin/marketplace.json +2 -2
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/deploy-docs.yml +3 -3
- package/.github/workflows/publish.yml +37 -0
- package/README.md +12 -3
- package/docs/index.html +13 -13
- package/docs/pages/changelog.html +39 -0
- package/docs/plans/2026-02-08-feat-convert-local-md-settings-for-opencode-codex-plan.md +143 -0
- package/docs/plans/2026-02-08-feat-simplify-plugin-settings-plan.md +195 -0
- package/docs/plans/2026-02-09-refactor-dspy-ruby-skill-update-plan.md +104 -0
- package/docs/plans/2026-02-12-feat-add-cursor-cli-target-provider-plan.md +306 -0
- package/docs/specs/cursor.md +85 -0
- package/package.json +1 -1
- package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
- package/plugins/compound-engineering/CHANGELOG.md +38 -0
- package/plugins/compound-engineering/README.md +5 -3
- package/plugins/compound-engineering/commands/workflows/brainstorm.md +6 -1
- package/plugins/compound-engineering/commands/workflows/compound.md +1 -0
- package/plugins/compound-engineering/commands/workflows/review.md +23 -21
- package/plugins/compound-engineering/commands/workflows/work.md +29 -15
- package/plugins/compound-engineering/skills/dspy-ruby/SKILL.md +539 -396
- package/plugins/compound-engineering/skills/dspy-ruby/assets/config-template.rb +159 -331
- package/plugins/compound-engineering/skills/dspy-ruby/assets/module-template.rb +210 -236
- package/plugins/compound-engineering/skills/dspy-ruby/assets/signature-template.rb +173 -95
- package/plugins/compound-engineering/skills/dspy-ruby/references/core-concepts.md +552 -143
- package/plugins/compound-engineering/skills/dspy-ruby/references/observability.md +366 -0
- package/plugins/compound-engineering/skills/dspy-ruby/references/optimization.md +440 -460
- package/plugins/compound-engineering/skills/dspy-ruby/references/providers.md +305 -225
- package/plugins/compound-engineering/skills/dspy-ruby/references/toolsets.md +502 -0
- package/plugins/compound-engineering/skills/setup/SKILL.md +168 -0
- package/src/commands/convert.ts +10 -5
- package/src/commands/install.ts +18 -10
- package/src/converters/claude-to-codex.ts +7 -2
- package/src/converters/claude-to-cursor.ts +166 -0
- package/src/converters/claude-to-droid.ts +174 -0
- package/src/converters/claude-to-opencode.ts +8 -2
- package/src/targets/cursor.ts +48 -0
- package/src/targets/droid.ts +50 -0
- package/src/targets/index.ts +18 -0
- package/src/types/cursor.ts +29 -0
- package/src/types/droid.ts +20 -0
- package/tests/cli.test.ts +62 -0
- package/tests/codex-converter.test.ts +62 -0
- package/tests/converter.test.ts +61 -0
- package/tests/cursor-converter.test.ts +347 -0
- package/tests/cursor-writer.test.ts +137 -0
- package/tests/droid-converter.test.ts +277 -0
- package/tests/droid-writer.test.ts +100 -0
- package/plugins/compound-engineering/commands/technical_review.md +0 -8
package/tests/cli.test.ts
CHANGED
|
@@ -180,6 +180,68 @@ describe("CLI", () => {
|
|
|
180
180
|
expect(await exists(path.join(tempRoot, ".config", "opencode", "agents", "repo-research-analyst.md"))).toBe(true)
|
|
181
181
|
})
|
|
182
182
|
|
|
183
|
+
test("install by name ignores same-named local directory", async () => {
|
|
184
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-"))
|
|
185
|
+
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-workspace-"))
|
|
186
|
+
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-repo-"))
|
|
187
|
+
|
|
188
|
+
// Create a directory with the plugin name that is NOT a valid plugin
|
|
189
|
+
const shadowDir = path.join(workspaceRoot, "compound-engineering")
|
|
190
|
+
await fs.mkdir(shadowDir, { recursive: true })
|
|
191
|
+
await fs.writeFile(path.join(shadowDir, "README.md"), "Not a plugin")
|
|
192
|
+
|
|
193
|
+
// Set up a fake GitHub source with a valid plugin
|
|
194
|
+
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
|
195
|
+
const pluginRoot = path.join(repoRoot, "plugins", "compound-engineering")
|
|
196
|
+
await fs.mkdir(path.dirname(pluginRoot), { recursive: true })
|
|
197
|
+
await fs.cp(fixtureRoot, pluginRoot, { recursive: true })
|
|
198
|
+
|
|
199
|
+
const gitEnv = {
|
|
200
|
+
...process.env,
|
|
201
|
+
GIT_AUTHOR_NAME: "Test",
|
|
202
|
+
GIT_AUTHOR_EMAIL: "test@example.com",
|
|
203
|
+
GIT_COMMITTER_NAME: "Test",
|
|
204
|
+
GIT_COMMITTER_EMAIL: "test@example.com",
|
|
205
|
+
}
|
|
206
|
+
await runGit(["init"], repoRoot, gitEnv)
|
|
207
|
+
await runGit(["add", "."], repoRoot, gitEnv)
|
|
208
|
+
await runGit(["commit", "-m", "fixture"], repoRoot, gitEnv)
|
|
209
|
+
|
|
210
|
+
const projectRoot = path.join(import.meta.dir, "..")
|
|
211
|
+
const proc = Bun.spawn([
|
|
212
|
+
"bun",
|
|
213
|
+
"run",
|
|
214
|
+
path.join(projectRoot, "src", "index.ts"),
|
|
215
|
+
"install",
|
|
216
|
+
"compound-engineering",
|
|
217
|
+
"--to",
|
|
218
|
+
"opencode",
|
|
219
|
+
"--output",
|
|
220
|
+
tempRoot,
|
|
221
|
+
], {
|
|
222
|
+
cwd: workspaceRoot,
|
|
223
|
+
stdout: "pipe",
|
|
224
|
+
stderr: "pipe",
|
|
225
|
+
env: {
|
|
226
|
+
...process.env,
|
|
227
|
+
HOME: tempRoot,
|
|
228
|
+
COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot,
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const exitCode = await proc.exited
|
|
233
|
+
const stdout = await new Response(proc.stdout).text()
|
|
234
|
+
const stderr = await new Response(proc.stderr).text()
|
|
235
|
+
|
|
236
|
+
if (exitCode !== 0) {
|
|
237
|
+
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Should succeed by fetching from GitHub, NOT failing on the local shadow directory
|
|
241
|
+
expect(stdout).toContain("Installed compound-engineering")
|
|
242
|
+
expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true)
|
|
243
|
+
})
|
|
244
|
+
|
|
183
245
|
test("convert writes OpenCode output", async () => {
|
|
184
246
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-convert-"))
|
|
185
247
|
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
|
@@ -210,6 +210,68 @@ Don't confuse with file paths like /tmp/output.md or /dev/null.`,
|
|
|
210
210
|
expect(commandSkills[0].name).toBe("normal-command")
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
+
test("rewrites .claude/ paths to .codex/ in command skill bodies", () => {
|
|
214
|
+
const plugin: ClaudePlugin = {
|
|
215
|
+
...fixturePlugin,
|
|
216
|
+
commands: [
|
|
217
|
+
{
|
|
218
|
+
name: "review",
|
|
219
|
+
description: "Review command",
|
|
220
|
+
body: `Read \`compound-engineering.local.md\` in the project root.
|
|
221
|
+
|
|
222
|
+
If no settings file exists, auto-detect project type.
|
|
223
|
+
|
|
224
|
+
Run \`/compound-engineering-setup\` to create a settings file.`,
|
|
225
|
+
sourcePath: "/tmp/plugin/commands/review.md",
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
agents: [],
|
|
229
|
+
skills: [],
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const bundle = convertClaudeToCodex(plugin, {
|
|
233
|
+
agentMode: "subagent",
|
|
234
|
+
inferTemperature: false,
|
|
235
|
+
permissions: "none",
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const commandSkill = bundle.generatedSkills.find((s) => s.name === "review")
|
|
239
|
+
expect(commandSkill).toBeDefined()
|
|
240
|
+
const parsed = parseFrontmatter(commandSkill!.content)
|
|
241
|
+
|
|
242
|
+
// Tool-agnostic path in project root — no rewriting needed
|
|
243
|
+
expect(parsed.body).toContain("compound-engineering.local.md")
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test("rewrites .claude/ paths in agent skill bodies", () => {
|
|
247
|
+
const plugin: ClaudePlugin = {
|
|
248
|
+
...fixturePlugin,
|
|
249
|
+
commands: [],
|
|
250
|
+
skills: [],
|
|
251
|
+
agents: [
|
|
252
|
+
{
|
|
253
|
+
name: "config-reader",
|
|
254
|
+
description: "Reads config",
|
|
255
|
+
body: "Read `compound-engineering.local.md` for config.",
|
|
256
|
+
sourcePath: "/tmp/plugin/agents/config-reader.md",
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const bundle = convertClaudeToCodex(plugin, {
|
|
262
|
+
agentMode: "subagent",
|
|
263
|
+
inferTemperature: false,
|
|
264
|
+
permissions: "none",
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const agentSkill = bundle.generatedSkills.find((s) => s.name === "config-reader")
|
|
268
|
+
expect(agentSkill).toBeDefined()
|
|
269
|
+
const parsed = parseFrontmatter(agentSkill!.content)
|
|
270
|
+
|
|
271
|
+
// Tool-agnostic path in project root — no rewriting needed
|
|
272
|
+
expect(parsed.body).toContain("compound-engineering.local.md")
|
|
273
|
+
})
|
|
274
|
+
|
|
213
275
|
test("truncates generated skill descriptions to Codex limits and single line", () => {
|
|
214
276
|
const longDescription = `Line one\nLine two ${"a".repeat(2000)}`
|
|
215
277
|
const plugin: ClaudePlugin = {
|
package/tests/converter.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "path"
|
|
|
3
3
|
import { loadClaudePlugin } from "../src/parsers/claude"
|
|
4
4
|
import { convertClaudeToOpenCode } from "../src/converters/claude-to-opencode"
|
|
5
5
|
import { parseFrontmatter } from "../src/utils/frontmatter"
|
|
6
|
+
import type { ClaudePlugin } from "../src/types/claude"
|
|
6
7
|
|
|
7
8
|
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
|
8
9
|
|
|
@@ -183,4 +184,64 @@ describe("convertClaudeToOpenCode", () => {
|
|
|
183
184
|
// Normal commands should still be present
|
|
184
185
|
expect(bundle.config.command?.["workflows:review"]).toBeDefined()
|
|
185
186
|
})
|
|
187
|
+
|
|
188
|
+
test("rewrites .claude/ paths to .opencode/ in command bodies", () => {
|
|
189
|
+
const plugin: ClaudePlugin = {
|
|
190
|
+
root: "/tmp/plugin",
|
|
191
|
+
manifest: { name: "fixture", version: "1.0.0" },
|
|
192
|
+
agents: [],
|
|
193
|
+
commands: [
|
|
194
|
+
{
|
|
195
|
+
name: "review",
|
|
196
|
+
description: "Review command",
|
|
197
|
+
body: `Read \`compound-engineering.local.md\` in the project root.
|
|
198
|
+
|
|
199
|
+
If no settings file exists, auto-detect project type.
|
|
200
|
+
|
|
201
|
+
Run \`/compound-engineering-setup\` to create a settings file.`,
|
|
202
|
+
sourcePath: "/tmp/plugin/commands/review.md",
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
skills: [],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const bundle = convertClaudeToOpenCode(plugin, {
|
|
209
|
+
agentMode: "subagent",
|
|
210
|
+
inferTemperature: false,
|
|
211
|
+
permissions: "none",
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const template = bundle.config.command?.["review"]?.template ?? ""
|
|
215
|
+
|
|
216
|
+
// Tool-agnostic path in project root — no rewriting needed
|
|
217
|
+
expect(template).toContain("compound-engineering.local.md")
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test("rewrites .claude/ paths in agent bodies", () => {
|
|
221
|
+
const plugin: ClaudePlugin = {
|
|
222
|
+
root: "/tmp/plugin",
|
|
223
|
+
manifest: { name: "fixture", version: "1.0.0" },
|
|
224
|
+
agents: [
|
|
225
|
+
{
|
|
226
|
+
name: "test-agent",
|
|
227
|
+
description: "Test agent",
|
|
228
|
+
body: "Read `compound-engineering.local.md` for config.",
|
|
229
|
+
sourcePath: "/tmp/plugin/agents/test-agent.md",
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
commands: [],
|
|
233
|
+
skills: [],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const bundle = convertClaudeToOpenCode(plugin, {
|
|
237
|
+
agentMode: "subagent",
|
|
238
|
+
inferTemperature: false,
|
|
239
|
+
permissions: "none",
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const agentFile = bundle.agents.find((a) => a.name === "test-agent")
|
|
243
|
+
expect(agentFile).toBeDefined()
|
|
244
|
+
// Tool-agnostic path in project root — no rewriting needed
|
|
245
|
+
expect(agentFile!.content).toContain("compound-engineering.local.md")
|
|
246
|
+
})
|
|
186
247
|
})
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { describe, expect, test, spyOn } from "bun:test"
|
|
2
|
+
import { convertClaudeToCursor, transformContentForCursor } from "../src/converters/claude-to-cursor"
|
|
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("convertClaudeToCursor", () => {
|
|
49
|
+
test("converts agents to rules with .mdc frontmatter", () => {
|
|
50
|
+
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
51
|
+
|
|
52
|
+
expect(bundle.rules).toHaveLength(1)
|
|
53
|
+
const rule = bundle.rules[0]
|
|
54
|
+
expect(rule.name).toBe("security-reviewer")
|
|
55
|
+
|
|
56
|
+
const parsed = parseFrontmatter(rule.content)
|
|
57
|
+
expect(parsed.data.description).toBe("Security-focused code review agent")
|
|
58
|
+
expect(parsed.data.alwaysApply).toBe(false)
|
|
59
|
+
// globs is omitted (Agent Requested mode doesn't need it)
|
|
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 with empty description gets default", () => {
|
|
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 = convertClaudeToCursor(plugin, defaultOptions)
|
|
78
|
+
const parsed = parseFrontmatter(bundle.rules[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 = convertClaudeToCursor(plugin, defaultOptions)
|
|
96
|
+
const parsed = parseFrontmatter(bundle.rules[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 = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
102
|
+
const parsed = parseFrontmatter(bundle.rules[0].content)
|
|
103
|
+
expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("agent model field is silently dropped", () => {
|
|
107
|
+
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
108
|
+
const parsed = parseFrontmatter(bundle.rules[0].content)
|
|
109
|
+
expect(parsed.data.model).toBeUndefined()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("flattens namespaced command names", () => {
|
|
113
|
+
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
114
|
+
|
|
115
|
+
expect(bundle.commands).toHaveLength(1)
|
|
116
|
+
const command = bundle.commands[0]
|
|
117
|
+
expect(command.name).toBe("plan")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("commands are plain markdown without frontmatter", () => {
|
|
121
|
+
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
122
|
+
const command = bundle.commands[0]
|
|
123
|
+
|
|
124
|
+
// Should NOT start with ---
|
|
125
|
+
expect(command.content.startsWith("---")).toBe(false)
|
|
126
|
+
// Should include the description as a comment
|
|
127
|
+
expect(command.content).toContain("<!-- Planning command -->")
|
|
128
|
+
expect(command.content).toContain("Plan the work.")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("command name collision after flattening is deduplicated", () => {
|
|
132
|
+
const plugin: ClaudePlugin = {
|
|
133
|
+
...fixturePlugin,
|
|
134
|
+
commands: [
|
|
135
|
+
{
|
|
136
|
+
name: "workflows:plan",
|
|
137
|
+
description: "Workflow plan",
|
|
138
|
+
body: "Plan body.",
|
|
139
|
+
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "plan",
|
|
143
|
+
description: "Top-level plan",
|
|
144
|
+
body: "Top plan body.",
|
|
145
|
+
sourcePath: "/tmp/plugin/commands/plan.md",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
agents: [],
|
|
149
|
+
skills: [],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
|
153
|
+
const names = bundle.commands.map((c) => c.name)
|
|
154
|
+
expect(names).toEqual(["plan", "plan-2"])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("command with disable-model-invocation is still included", () => {
|
|
158
|
+
const plugin: ClaudePlugin = {
|
|
159
|
+
...fixturePlugin,
|
|
160
|
+
commands: [
|
|
161
|
+
{
|
|
162
|
+
name: "setup",
|
|
163
|
+
description: "Setup command",
|
|
164
|
+
disableModelInvocation: true,
|
|
165
|
+
body: "Setup body.",
|
|
166
|
+
sourcePath: "/tmp/plugin/commands/setup.md",
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
agents: [],
|
|
170
|
+
skills: [],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
|
174
|
+
expect(bundle.commands).toHaveLength(1)
|
|
175
|
+
expect(bundle.commands[0].name).toBe("setup")
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test("command allowedTools is silently dropped", () => {
|
|
179
|
+
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
180
|
+
const command = bundle.commands[0]
|
|
181
|
+
expect(command.content).not.toContain("allowedTools")
|
|
182
|
+
expect(command.content).not.toContain("Read")
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test("command with argument-hint gets Arguments section", () => {
|
|
186
|
+
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
187
|
+
const command = bundle.commands[0]
|
|
188
|
+
expect(command.content).toContain("## Arguments")
|
|
189
|
+
expect(command.content).toContain("[FOCUS]")
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("passes through skill directories", () => {
|
|
193
|
+
const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
194
|
+
|
|
195
|
+
expect(bundle.skillDirs).toHaveLength(1)
|
|
196
|
+
expect(bundle.skillDirs[0].name).toBe("existing-skill")
|
|
197
|
+
expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill")
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("converts MCP servers to JSON config", () => {
|
|
201
|
+
const plugin: ClaudePlugin = {
|
|
202
|
+
...fixturePlugin,
|
|
203
|
+
agents: [],
|
|
204
|
+
commands: [],
|
|
205
|
+
skills: [],
|
|
206
|
+
mcpServers: {
|
|
207
|
+
playwright: {
|
|
208
|
+
command: "npx",
|
|
209
|
+
args: ["-y", "@anthropic/mcp-playwright"],
|
|
210
|
+
env: { DISPLAY: ":0" },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
|
216
|
+
expect(bundle.mcpServers).toBeDefined()
|
|
217
|
+
expect(bundle.mcpServers!.playwright.command).toBe("npx")
|
|
218
|
+
expect(bundle.mcpServers!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"])
|
|
219
|
+
expect(bundle.mcpServers!.playwright.env).toEqual({ DISPLAY: ":0" })
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("MCP headers pass through for remote servers", () => {
|
|
223
|
+
const plugin: ClaudePlugin = {
|
|
224
|
+
...fixturePlugin,
|
|
225
|
+
agents: [],
|
|
226
|
+
commands: [],
|
|
227
|
+
skills: [],
|
|
228
|
+
mcpServers: {
|
|
229
|
+
remote: {
|
|
230
|
+
url: "https://mcp.example.com/sse",
|
|
231
|
+
headers: { Authorization: "Bearer token" },
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
|
237
|
+
expect(bundle.mcpServers!.remote.url).toBe("https://mcp.example.com/sse")
|
|
238
|
+
expect(bundle.mcpServers!.remote.headers).toEqual({ Authorization: "Bearer token" })
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("warns when hooks are present", () => {
|
|
242
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
|
|
243
|
+
|
|
244
|
+
const plugin: ClaudePlugin = {
|
|
245
|
+
...fixturePlugin,
|
|
246
|
+
agents: [],
|
|
247
|
+
commands: [],
|
|
248
|
+
skills: [],
|
|
249
|
+
hooks: {
|
|
250
|
+
hooks: {
|
|
251
|
+
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
convertClaudeToCursor(plugin, defaultOptions)
|
|
257
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
258
|
+
"Warning: Cursor does not support hooks. Hooks were skipped during conversion.",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
warnSpy.mockRestore()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test("no warning when hooks are absent", () => {
|
|
265
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {})
|
|
266
|
+
|
|
267
|
+
convertClaudeToCursor(fixturePlugin, defaultOptions)
|
|
268
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
269
|
+
|
|
270
|
+
warnSpy.mockRestore()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test("plugin with zero agents produces empty rules array", () => {
|
|
274
|
+
const plugin: ClaudePlugin = {
|
|
275
|
+
...fixturePlugin,
|
|
276
|
+
agents: [],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
|
280
|
+
expect(bundle.rules).toHaveLength(0)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test("plugin with only skills works", () => {
|
|
284
|
+
const plugin: ClaudePlugin = {
|
|
285
|
+
...fixturePlugin,
|
|
286
|
+
agents: [],
|
|
287
|
+
commands: [],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const bundle = convertClaudeToCursor(plugin, defaultOptions)
|
|
291
|
+
expect(bundle.rules).toHaveLength(0)
|
|
292
|
+
expect(bundle.commands).toHaveLength(0)
|
|
293
|
+
expect(bundle.skillDirs).toHaveLength(1)
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe("transformContentForCursor", () => {
|
|
298
|
+
test("rewrites .claude/ paths to .cursor/", () => {
|
|
299
|
+
const input = "Read `.claude/compound-engineering.local.md` for config."
|
|
300
|
+
const result = transformContentForCursor(input)
|
|
301
|
+
expect(result).toContain(".cursor/compound-engineering.local.md")
|
|
302
|
+
expect(result).not.toContain(".claude/")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test("rewrites ~/.claude/ paths to ~/.cursor/", () => {
|
|
306
|
+
const input = "Global config at ~/.claude/settings.json"
|
|
307
|
+
const result = transformContentForCursor(input)
|
|
308
|
+
expect(result).toContain("~/.cursor/settings.json")
|
|
309
|
+
expect(result).not.toContain("~/.claude/")
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test("transforms Task agent calls to skill references", () => {
|
|
313
|
+
const input = `Run agents:
|
|
314
|
+
|
|
315
|
+
- Task repo-research-analyst(feature_description)
|
|
316
|
+
- Task learnings-researcher(feature_description)
|
|
317
|
+
|
|
318
|
+
Task best-practices-researcher(topic)`
|
|
319
|
+
|
|
320
|
+
const result = transformContentForCursor(input)
|
|
321
|
+
expect(result).toContain("Use the repo-research-analyst skill to: feature_description")
|
|
322
|
+
expect(result).toContain("Use the learnings-researcher skill to: feature_description")
|
|
323
|
+
expect(result).toContain("Use the best-practices-researcher skill to: topic")
|
|
324
|
+
expect(result).not.toContain("Task repo-research-analyst(")
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test("flattens slash commands", () => {
|
|
328
|
+
const input = `1. Run /deepen-plan to enhance
|
|
329
|
+
2. Start /workflows:work to implement
|
|
330
|
+
3. File at /tmp/output.md`
|
|
331
|
+
|
|
332
|
+
const result = transformContentForCursor(input)
|
|
333
|
+
expect(result).toContain("/deepen-plan")
|
|
334
|
+
expect(result).toContain("/work")
|
|
335
|
+
expect(result).not.toContain("/workflows:work")
|
|
336
|
+
// File paths preserved
|
|
337
|
+
expect(result).toContain("/tmp/output.md")
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test("transforms @agent references to rule references", () => {
|
|
341
|
+
const input = "Have @security-sentinel and @dhh-rails-reviewer check the code."
|
|
342
|
+
const result = transformContentForCursor(input)
|
|
343
|
+
expect(result).toContain("the security-sentinel rule")
|
|
344
|
+
expect(result).toContain("the dhh-rails-reviewer rule")
|
|
345
|
+
expect(result).not.toContain("@security-sentinel")
|
|
346
|
+
})
|
|
347
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
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 { writeCursorBundle } from "../src/targets/cursor"
|
|
6
|
+
import type { CursorBundle } from "../src/types/cursor"
|
|
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("writeCursorBundle", () => {
|
|
18
|
+
test("writes rules, commands, skills, and mcp.json", async () => {
|
|
19
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-test-"))
|
|
20
|
+
const bundle: CursorBundle = {
|
|
21
|
+
rules: [{ name: "security-reviewer", content: "---\ndescription: Security\nglobs: \"\"\nalwaysApply: false\n---\n\nReview code." }],
|
|
22
|
+
commands: [{ name: "plan", content: "<!-- Planning -->\n\nPlan the work." }],
|
|
23
|
+
skillDirs: [
|
|
24
|
+
{
|
|
25
|
+
name: "skill-one",
|
|
26
|
+
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
mcpServers: {
|
|
30
|
+
playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] },
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await writeCursorBundle(tempRoot, bundle)
|
|
35
|
+
|
|
36
|
+
expect(await exists(path.join(tempRoot, ".cursor", "rules", "security-reviewer.mdc"))).toBe(true)
|
|
37
|
+
expect(await exists(path.join(tempRoot, ".cursor", "commands", "plan.md"))).toBe(true)
|
|
38
|
+
expect(await exists(path.join(tempRoot, ".cursor", "skills", "skill-one", "SKILL.md"))).toBe(true)
|
|
39
|
+
expect(await exists(path.join(tempRoot, ".cursor", "mcp.json"))).toBe(true)
|
|
40
|
+
|
|
41
|
+
const ruleContent = await fs.readFile(
|
|
42
|
+
path.join(tempRoot, ".cursor", "rules", "security-reviewer.mdc"),
|
|
43
|
+
"utf8",
|
|
44
|
+
)
|
|
45
|
+
expect(ruleContent).toContain("Review code.")
|
|
46
|
+
|
|
47
|
+
const commandContent = await fs.readFile(
|
|
48
|
+
path.join(tempRoot, ".cursor", "commands", "plan.md"),
|
|
49
|
+
"utf8",
|
|
50
|
+
)
|
|
51
|
+
expect(commandContent).toContain("Plan the work.")
|
|
52
|
+
|
|
53
|
+
const mcpContent = JSON.parse(
|
|
54
|
+
await fs.readFile(path.join(tempRoot, ".cursor", "mcp.json"), "utf8"),
|
|
55
|
+
)
|
|
56
|
+
expect(mcpContent.mcpServers.playwright.command).toBe("npx")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("writes directly into a .cursor output root without double-nesting", async () => {
|
|
60
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-home-"))
|
|
61
|
+
const cursorRoot = path.join(tempRoot, ".cursor")
|
|
62
|
+
const bundle: CursorBundle = {
|
|
63
|
+
rules: [{ name: "reviewer", content: "Reviewer rule content" }],
|
|
64
|
+
commands: [{ name: "plan", content: "Plan content" }],
|
|
65
|
+
skillDirs: [],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await writeCursorBundle(cursorRoot, bundle)
|
|
69
|
+
|
|
70
|
+
expect(await exists(path.join(cursorRoot, "rules", "reviewer.mdc"))).toBe(true)
|
|
71
|
+
expect(await exists(path.join(cursorRoot, "commands", "plan.md"))).toBe(true)
|
|
72
|
+
// Should NOT double-nest under .cursor/.cursor
|
|
73
|
+
expect(await exists(path.join(cursorRoot, ".cursor"))).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("handles empty bundles gracefully", async () => {
|
|
77
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-empty-"))
|
|
78
|
+
const bundle: CursorBundle = {
|
|
79
|
+
rules: [],
|
|
80
|
+
commands: [],
|
|
81
|
+
skillDirs: [],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await writeCursorBundle(tempRoot, bundle)
|
|
85
|
+
expect(await exists(tempRoot)).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("writes multiple rules as separate .mdc files", async () => {
|
|
89
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-multi-"))
|
|
90
|
+
const cursorRoot = path.join(tempRoot, ".cursor")
|
|
91
|
+
const bundle: CursorBundle = {
|
|
92
|
+
rules: [
|
|
93
|
+
{ name: "security-sentinel", content: "Security rules" },
|
|
94
|
+
{ name: "performance-oracle", content: "Performance rules" },
|
|
95
|
+
{ name: "code-simplicity-reviewer", content: "Simplicity rules" },
|
|
96
|
+
],
|
|
97
|
+
commands: [],
|
|
98
|
+
skillDirs: [],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await writeCursorBundle(cursorRoot, bundle)
|
|
102
|
+
|
|
103
|
+
expect(await exists(path.join(cursorRoot, "rules", "security-sentinel.mdc"))).toBe(true)
|
|
104
|
+
expect(await exists(path.join(cursorRoot, "rules", "performance-oracle.mdc"))).toBe(true)
|
|
105
|
+
expect(await exists(path.join(cursorRoot, "rules", "code-simplicity-reviewer.mdc"))).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("backs up existing mcp.json before overwriting", async () => {
|
|
109
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-backup-"))
|
|
110
|
+
const cursorRoot = path.join(tempRoot, ".cursor")
|
|
111
|
+
await fs.mkdir(cursorRoot, { recursive: true })
|
|
112
|
+
|
|
113
|
+
// Write an existing mcp.json
|
|
114
|
+
const mcpPath = path.join(cursorRoot, "mcp.json")
|
|
115
|
+
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } }))
|
|
116
|
+
|
|
117
|
+
const bundle: CursorBundle = {
|
|
118
|
+
rules: [],
|
|
119
|
+
commands: [],
|
|
120
|
+
skillDirs: [],
|
|
121
|
+
mcpServers: {
|
|
122
|
+
newServer: { command: "new-cmd" },
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await writeCursorBundle(cursorRoot, bundle)
|
|
127
|
+
|
|
128
|
+
// New mcp.json should have the new content
|
|
129
|
+
const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8"))
|
|
130
|
+
expect(newContent.mcpServers.newServer.command).toBe("new-cmd")
|
|
131
|
+
|
|
132
|
+
// A backup file should exist
|
|
133
|
+
const files = await fs.readdir(cursorRoot)
|
|
134
|
+
const backupFiles = files.filter((f) => f.startsWith("mcp.json.bak."))
|
|
135
|
+
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
|
|
136
|
+
})
|
|
137
|
+
})
|