@every-env/compound-plugin 0.7.0 → 0.9.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.
- package/.cursor-plugin/marketplace.json +25 -0
- package/CHANGELOG.md +21 -0
- package/README.md +18 -8
- package/bun.lock +1 -0
- package/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md +117 -0
- package/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md +30 -0
- package/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md +328 -0
- package/docs/specs/copilot.md +122 -0
- package/docs/specs/kiro.md +171 -0
- package/package.json +1 -1
- package/plugins/coding-tutor/.cursor-plugin/plugin.json +21 -0
- package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
- package/plugins/compound-engineering/.cursor-plugin/plugin.json +31 -0
- package/plugins/compound-engineering/.mcp.json +8 -0
- package/plugins/compound-engineering/CHANGELOG.md +10 -0
- package/plugins/compound-engineering/commands/lfg.md +3 -3
- package/plugins/compound-engineering/commands/slfg.md +2 -2
- package/plugins/compound-engineering/commands/workflows/plan.md +15 -1
- package/src/commands/convert.ts +2 -1
- package/src/commands/install.ts +9 -1
- package/src/commands/sync.ts +8 -8
- package/src/converters/{claude-to-cursor.ts → claude-to-copilot.ts} +93 -49
- package/src/converters/claude-to-kiro.ts +262 -0
- package/src/sync/{cursor.ts → copilot.ts} +36 -14
- package/src/targets/copilot.ts +48 -0
- package/src/targets/index.ts +18 -9
- package/src/targets/kiro.ts +122 -0
- package/src/types/copilot.ts +31 -0
- package/src/types/kiro.ts +44 -0
- package/src/utils/frontmatter.ts +1 -1
- package/tests/copilot-converter.test.ts +467 -0
- package/tests/copilot-writer.test.ts +189 -0
- package/tests/kiro-converter.test.ts +381 -0
- package/tests/kiro-writer.test.ts +273 -0
- package/tests/sync-copilot.test.ts +148 -0
- package/src/targets/cursor.ts +0 -48
- package/src/types/cursor.ts +0 -29
- package/tests/cursor-converter.test.ts +0 -347
- package/tests/cursor-writer.test.ts +0 -137
- package/tests/sync-cursor.test.ts +0 -92
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { convertClaudeToKiro, transformContentForKiro } from "../src/converters/claude-to-kiro"
|
|
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 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: {
|
|
40
|
+
local: { command: "echo", args: ["hello"] },
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const defaultOptions = {
|
|
45
|
+
agentMode: "subagent" as const,
|
|
46
|
+
inferTemperature: false,
|
|
47
|
+
permissions: "none" as const,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("convertClaudeToKiro", () => {
|
|
51
|
+
test("converts agents to Kiro agent configs with prompt files", () => {
|
|
52
|
+
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
|
53
|
+
|
|
54
|
+
const agent = bundle.agents.find((a) => a.name === "security-reviewer")
|
|
55
|
+
expect(agent).toBeDefined()
|
|
56
|
+
expect(agent!.config.name).toBe("security-reviewer")
|
|
57
|
+
expect(agent!.config.description).toBe("Security-focused agent")
|
|
58
|
+
expect(agent!.config.prompt).toBe("file://./prompts/security-reviewer.md")
|
|
59
|
+
expect(agent!.config.tools).toEqual(["*"])
|
|
60
|
+
expect(agent!.config.includeMcpJson).toBe(true)
|
|
61
|
+
expect(agent!.config.resources).toContain("file://.kiro/steering/**/*.md")
|
|
62
|
+
expect(agent!.config.resources).toContain("skill://.kiro/skills/**/SKILL.md")
|
|
63
|
+
expect(agent!.promptContent).toContain("Focus on vulnerabilities.")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("agent config has welcomeMessage generated from description", () => {
|
|
67
|
+
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
|
68
|
+
const agent = bundle.agents.find((a) => a.name === "security-reviewer")
|
|
69
|
+
expect(agent!.config.welcomeMessage).toContain("security-reviewer")
|
|
70
|
+
expect(agent!.config.welcomeMessage).toContain("Security-focused agent")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("agent with capabilities prepended to prompt content", () => {
|
|
74
|
+
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
|
75
|
+
const agent = bundle.agents.find((a) => a.name === "security-reviewer")
|
|
76
|
+
expect(agent!.promptContent).toContain("## Capabilities")
|
|
77
|
+
expect(agent!.promptContent).toContain("- Threat modeling")
|
|
78
|
+
expect(agent!.promptContent).toContain("- OWASP")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("agent with empty description gets default description", () => {
|
|
82
|
+
const plugin: ClaudePlugin = {
|
|
83
|
+
...fixturePlugin,
|
|
84
|
+
agents: [
|
|
85
|
+
{
|
|
86
|
+
name: "my-agent",
|
|
87
|
+
body: "Do things.",
|
|
88
|
+
sourcePath: "/tmp/plugin/agents/my-agent.md",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
commands: [],
|
|
92
|
+
skills: [],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
96
|
+
expect(bundle.agents[0].config.description).toBe("Use this agent for my-agent tasks")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("agent model field silently dropped", () => {
|
|
100
|
+
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
|
101
|
+
const agent = bundle.agents.find((a) => a.name === "security-reviewer")
|
|
102
|
+
expect((agent!.config as Record<string, unknown>).model).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("agent with empty body gets default body text", () => {
|
|
106
|
+
const plugin: ClaudePlugin = {
|
|
107
|
+
...fixturePlugin,
|
|
108
|
+
agents: [
|
|
109
|
+
{
|
|
110
|
+
name: "Empty Agent",
|
|
111
|
+
description: "An empty agent",
|
|
112
|
+
body: "",
|
|
113
|
+
sourcePath: "/tmp/plugin/agents/empty.md",
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
commands: [],
|
|
117
|
+
skills: [],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
121
|
+
expect(bundle.agents[0].promptContent).toContain("Instructions converted from the Empty Agent agent.")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("converts commands to SKILL.md with valid frontmatter", () => {
|
|
125
|
+
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
|
126
|
+
|
|
127
|
+
expect(bundle.generatedSkills).toHaveLength(1)
|
|
128
|
+
const skill = bundle.generatedSkills[0]
|
|
129
|
+
expect(skill.name).toBe("workflows-plan")
|
|
130
|
+
const parsed = parseFrontmatter(skill.content)
|
|
131
|
+
expect(parsed.data.name).toBe("workflows-plan")
|
|
132
|
+
expect(parsed.data.description).toBe("Planning command")
|
|
133
|
+
expect(parsed.body).toContain("Plan the work.")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("command with disable-model-invocation is still included", () => {
|
|
137
|
+
const plugin: ClaudePlugin = {
|
|
138
|
+
...fixturePlugin,
|
|
139
|
+
commands: [
|
|
140
|
+
{
|
|
141
|
+
name: "disabled-command",
|
|
142
|
+
description: "Disabled command",
|
|
143
|
+
disableModelInvocation: true,
|
|
144
|
+
body: "Disabled body.",
|
|
145
|
+
sourcePath: "/tmp/plugin/commands/disabled.md",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
agents: [],
|
|
149
|
+
skills: [],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
153
|
+
expect(bundle.generatedSkills).toHaveLength(1)
|
|
154
|
+
expect(bundle.generatedSkills[0].name).toBe("disabled-command")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("command allowedTools silently dropped", () => {
|
|
158
|
+
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
|
159
|
+
const skill = bundle.generatedSkills[0]
|
|
160
|
+
expect(skill.content).not.toContain("allowedTools")
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("skills pass through as directory references", () => {
|
|
164
|
+
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
|
165
|
+
|
|
166
|
+
expect(bundle.skillDirs).toHaveLength(1)
|
|
167
|
+
expect(bundle.skillDirs[0].name).toBe("existing-skill")
|
|
168
|
+
expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test("MCP stdio servers convert to mcp.json-compatible config", () => {
|
|
172
|
+
const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions)
|
|
173
|
+
expect(bundle.mcpServers.local.command).toBe("echo")
|
|
174
|
+
expect(bundle.mcpServers.local.args).toEqual(["hello"])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("MCP HTTP servers skipped with warning", () => {
|
|
178
|
+
const warnings: string[] = []
|
|
179
|
+
const originalWarn = console.warn
|
|
180
|
+
console.warn = (msg: string) => warnings.push(msg)
|
|
181
|
+
|
|
182
|
+
const plugin: ClaudePlugin = {
|
|
183
|
+
...fixturePlugin,
|
|
184
|
+
mcpServers: {
|
|
185
|
+
httpServer: { url: "https://example.com/mcp" },
|
|
186
|
+
},
|
|
187
|
+
agents: [],
|
|
188
|
+
commands: [],
|
|
189
|
+
skills: [],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
193
|
+
console.warn = originalWarn
|
|
194
|
+
|
|
195
|
+
expect(Object.keys(bundle.mcpServers)).toHaveLength(0)
|
|
196
|
+
expect(warnings.some((w) => w.includes("no command") || w.includes("HTTP"))).toBe(true)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test("plugin with zero agents produces empty agents array", () => {
|
|
200
|
+
const plugin: ClaudePlugin = {
|
|
201
|
+
...fixturePlugin,
|
|
202
|
+
agents: [],
|
|
203
|
+
commands: [],
|
|
204
|
+
skills: [],
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
208
|
+
expect(bundle.agents).toHaveLength(0)
|
|
209
|
+
expect(bundle.generatedSkills).toHaveLength(0)
|
|
210
|
+
expect(bundle.skillDirs).toHaveLength(0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("plugin with only skills works correctly", () => {
|
|
214
|
+
const plugin: ClaudePlugin = {
|
|
215
|
+
...fixturePlugin,
|
|
216
|
+
agents: [],
|
|
217
|
+
commands: [],
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
221
|
+
expect(bundle.agents).toHaveLength(0)
|
|
222
|
+
expect(bundle.generatedSkills).toHaveLength(0)
|
|
223
|
+
expect(bundle.skillDirs).toHaveLength(1)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test("skill name colliding with command name: command gets deduplicated", () => {
|
|
227
|
+
const plugin: ClaudePlugin = {
|
|
228
|
+
...fixturePlugin,
|
|
229
|
+
skills: [{ name: "my-command", description: "Existing skill", sourceDir: "/tmp/skill", skillPath: "/tmp/skill/SKILL.md" }],
|
|
230
|
+
commands: [{ name: "my-command", description: "A command", body: "Body.", sourcePath: "/tmp/commands/cmd.md" }],
|
|
231
|
+
agents: [],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
235
|
+
|
|
236
|
+
// Skill keeps original name, command gets deduplicated
|
|
237
|
+
expect(bundle.skillDirs[0].name).toBe("my-command")
|
|
238
|
+
expect(bundle.generatedSkills[0].name).toBe("my-command-2")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("hooks present emits console.warn", () => {
|
|
242
|
+
const warnings: string[] = []
|
|
243
|
+
const originalWarn = console.warn
|
|
244
|
+
console.warn = (msg: string) => warnings.push(msg)
|
|
245
|
+
|
|
246
|
+
const plugin: ClaudePlugin = {
|
|
247
|
+
...fixturePlugin,
|
|
248
|
+
hooks: { hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "echo test" }] }] } },
|
|
249
|
+
agents: [],
|
|
250
|
+
commands: [],
|
|
251
|
+
skills: [],
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
convertClaudeToKiro(plugin, defaultOptions)
|
|
255
|
+
console.warn = originalWarn
|
|
256
|
+
|
|
257
|
+
expect(warnings.some((w) => w.includes("Kiro"))).toBe(true)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test("steering file not generated when CLAUDE.md missing", () => {
|
|
261
|
+
const plugin: ClaudePlugin = {
|
|
262
|
+
...fixturePlugin,
|
|
263
|
+
root: "/tmp/nonexistent-plugin-dir",
|
|
264
|
+
agents: [],
|
|
265
|
+
commands: [],
|
|
266
|
+
skills: [],
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
270
|
+
expect(bundle.steeringFiles).toHaveLength(0)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test("name normalization handles various inputs", () => {
|
|
274
|
+
const plugin: ClaudePlugin = {
|
|
275
|
+
...fixturePlugin,
|
|
276
|
+
agents: [
|
|
277
|
+
{ name: "My Cool Agent!!!", description: "Cool", body: "Body.", sourcePath: "/tmp/a.md" },
|
|
278
|
+
{ name: "UPPERCASE-AGENT", description: "Upper", body: "Body.", sourcePath: "/tmp/b.md" },
|
|
279
|
+
{ name: "agent--with--double-hyphens", description: "Hyphens", body: "Body.", sourcePath: "/tmp/c.md" },
|
|
280
|
+
],
|
|
281
|
+
commands: [],
|
|
282
|
+
skills: [],
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
286
|
+
expect(bundle.agents[0].name).toBe("my-cool-agent")
|
|
287
|
+
expect(bundle.agents[1].name).toBe("uppercase-agent")
|
|
288
|
+
expect(bundle.agents[2].name).toBe("agent-with-double-hyphens") // collapsed
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test("description truncation to 1024 chars", () => {
|
|
292
|
+
const longDesc = "a".repeat(2000)
|
|
293
|
+
const plugin: ClaudePlugin = {
|
|
294
|
+
...fixturePlugin,
|
|
295
|
+
agents: [
|
|
296
|
+
{ name: "long-desc", description: longDesc, body: "Body.", sourcePath: "/tmp/a.md" },
|
|
297
|
+
],
|
|
298
|
+
commands: [],
|
|
299
|
+
skills: [],
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
303
|
+
expect(bundle.agents[0].config.description.length).toBeLessThanOrEqual(1024)
|
|
304
|
+
expect(bundle.agents[0].config.description.endsWith("...")).toBe(true)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test("empty plugin produces empty bundle", () => {
|
|
308
|
+
const plugin: ClaudePlugin = {
|
|
309
|
+
root: "/tmp/empty",
|
|
310
|
+
manifest: { name: "empty", version: "1.0.0" },
|
|
311
|
+
agents: [],
|
|
312
|
+
commands: [],
|
|
313
|
+
skills: [],
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const bundle = convertClaudeToKiro(plugin, defaultOptions)
|
|
317
|
+
expect(bundle.agents).toHaveLength(0)
|
|
318
|
+
expect(bundle.generatedSkills).toHaveLength(0)
|
|
319
|
+
expect(bundle.skillDirs).toHaveLength(0)
|
|
320
|
+
expect(bundle.steeringFiles).toHaveLength(0)
|
|
321
|
+
expect(Object.keys(bundle.mcpServers)).toHaveLength(0)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe("transformContentForKiro", () => {
|
|
326
|
+
test("transforms .claude/ paths to .kiro/", () => {
|
|
327
|
+
const result = transformContentForKiro("Read .claude/settings.json for config.")
|
|
328
|
+
expect(result).toContain(".kiro/settings.json")
|
|
329
|
+
expect(result).not.toContain(".claude/")
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test("transforms ~/.claude/ paths to ~/.kiro/", () => {
|
|
333
|
+
const result = transformContentForKiro("Check ~/.claude/config for settings.")
|
|
334
|
+
expect(result).toContain("~/.kiro/config")
|
|
335
|
+
expect(result).not.toContain("~/.claude/")
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test("transforms Task agent(args) to use_subagent reference", () => {
|
|
339
|
+
const input = `Run these:
|
|
340
|
+
|
|
341
|
+
- Task repo-research-analyst(feature_description)
|
|
342
|
+
- Task learnings-researcher(feature_description)
|
|
343
|
+
|
|
344
|
+
Task best-practices-researcher(topic)`
|
|
345
|
+
|
|
346
|
+
const result = transformContentForKiro(input)
|
|
347
|
+
expect(result).toContain("Use the use_subagent tool to delegate to the repo-research-analyst agent: feature_description")
|
|
348
|
+
expect(result).toContain("Use the use_subagent tool to delegate to the learnings-researcher agent: feature_description")
|
|
349
|
+
expect(result).toContain("Use the use_subagent tool to delegate to the best-practices-researcher agent: topic")
|
|
350
|
+
expect(result).not.toContain("Task repo-research-analyst")
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test("transforms @agent references for known agents only", () => {
|
|
354
|
+
const result = transformContentForKiro("Ask @security-sentinel for a review.", ["security-sentinel"])
|
|
355
|
+
expect(result).toContain("the security-sentinel agent")
|
|
356
|
+
expect(result).not.toContain("@security-sentinel")
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test("does not transform @unknown-name when not in known agents", () => {
|
|
360
|
+
const result = transformContentForKiro("Contact @someone-else for help.", ["security-sentinel"])
|
|
361
|
+
expect(result).toContain("@someone-else")
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test("transforms Claude tool names to Kiro equivalents", () => {
|
|
365
|
+
const result = transformContentForKiro("Use the Bash tool to run commands. Use Read to check files.")
|
|
366
|
+
expect(result).toContain("shell tool")
|
|
367
|
+
expect(result).toContain("read to")
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test("transforms slash command refs to skill activation", () => {
|
|
371
|
+
const result = transformContentForKiro("Run /workflows:plan to start planning.")
|
|
372
|
+
expect(result).toContain("the workflows-plan skill")
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("does not transform partial .claude paths like package/.claude-config/", () => {
|
|
376
|
+
const result = transformContentForKiro("Check some-package/.claude-config/settings")
|
|
377
|
+
// The .claude-config/ part should be transformed since it starts with .claude/
|
|
378
|
+
// but only when preceded by a word boundary
|
|
379
|
+
expect(result).toContain("some-package/")
|
|
380
|
+
})
|
|
381
|
+
})
|
|
@@ -0,0 +1,273 @@
|
|
|
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 { writeKiroBundle } from "../src/targets/kiro"
|
|
6
|
+
import type { KiroBundle } from "../src/types/kiro"
|
|
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
|
+
const emptyBundle: KiroBundle = {
|
|
18
|
+
agents: [],
|
|
19
|
+
generatedSkills: [],
|
|
20
|
+
skillDirs: [],
|
|
21
|
+
steeringFiles: [],
|
|
22
|
+
mcpServers: {},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("writeKiroBundle", () => {
|
|
26
|
+
test("writes agents, skills, steering, and mcp.json", async () => {
|
|
27
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-test-"))
|
|
28
|
+
const bundle: KiroBundle = {
|
|
29
|
+
agents: [
|
|
30
|
+
{
|
|
31
|
+
name: "security-reviewer",
|
|
32
|
+
config: {
|
|
33
|
+
name: "security-reviewer",
|
|
34
|
+
description: "Security-focused agent",
|
|
35
|
+
prompt: "file://./prompts/security-reviewer.md",
|
|
36
|
+
tools: ["*"],
|
|
37
|
+
resources: ["file://.kiro/steering/**/*.md", "skill://.kiro/skills/**/SKILL.md"],
|
|
38
|
+
includeMcpJson: true,
|
|
39
|
+
welcomeMessage: "Switching to security-reviewer.",
|
|
40
|
+
},
|
|
41
|
+
promptContent: "Review code for vulnerabilities.",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
generatedSkills: [
|
|
45
|
+
{
|
|
46
|
+
name: "workflows-plan",
|
|
47
|
+
content: "---\nname: workflows-plan\ndescription: Planning\n---\n\nPlan the work.",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
skillDirs: [
|
|
51
|
+
{
|
|
52
|
+
name: "skill-one",
|
|
53
|
+
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
steeringFiles: [
|
|
57
|
+
{ name: "compound-engineering", content: "# Steering content\n\nFollow these guidelines." },
|
|
58
|
+
],
|
|
59
|
+
mcpServers: {
|
|
60
|
+
playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] },
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await writeKiroBundle(tempRoot, bundle)
|
|
65
|
+
|
|
66
|
+
// Agent JSON config
|
|
67
|
+
const agentConfigPath = path.join(tempRoot, ".kiro", "agents", "security-reviewer.json")
|
|
68
|
+
expect(await exists(agentConfigPath)).toBe(true)
|
|
69
|
+
const agentConfig = JSON.parse(await fs.readFile(agentConfigPath, "utf8"))
|
|
70
|
+
expect(agentConfig.name).toBe("security-reviewer")
|
|
71
|
+
expect(agentConfig.includeMcpJson).toBe(true)
|
|
72
|
+
expect(agentConfig.tools).toEqual(["*"])
|
|
73
|
+
|
|
74
|
+
// Agent prompt file
|
|
75
|
+
const promptPath = path.join(tempRoot, ".kiro", "agents", "prompts", "security-reviewer.md")
|
|
76
|
+
expect(await exists(promptPath)).toBe(true)
|
|
77
|
+
const promptContent = await fs.readFile(promptPath, "utf8")
|
|
78
|
+
expect(promptContent).toContain("Review code for vulnerabilities.")
|
|
79
|
+
|
|
80
|
+
// Generated skill
|
|
81
|
+
const skillPath = path.join(tempRoot, ".kiro", "skills", "workflows-plan", "SKILL.md")
|
|
82
|
+
expect(await exists(skillPath)).toBe(true)
|
|
83
|
+
const skillContent = await fs.readFile(skillPath, "utf8")
|
|
84
|
+
expect(skillContent).toContain("Plan the work.")
|
|
85
|
+
|
|
86
|
+
// Copied skill
|
|
87
|
+
expect(await exists(path.join(tempRoot, ".kiro", "skills", "skill-one", "SKILL.md"))).toBe(true)
|
|
88
|
+
|
|
89
|
+
// Steering file
|
|
90
|
+
const steeringPath = path.join(tempRoot, ".kiro", "steering", "compound-engineering.md")
|
|
91
|
+
expect(await exists(steeringPath)).toBe(true)
|
|
92
|
+
const steeringContent = await fs.readFile(steeringPath, "utf8")
|
|
93
|
+
expect(steeringContent).toContain("Follow these guidelines.")
|
|
94
|
+
|
|
95
|
+
// MCP config
|
|
96
|
+
const mcpPath = path.join(tempRoot, ".kiro", "settings", "mcp.json")
|
|
97
|
+
expect(await exists(mcpPath)).toBe(true)
|
|
98
|
+
const mcpContent = JSON.parse(await fs.readFile(mcpPath, "utf8"))
|
|
99
|
+
expect(mcpContent.mcpServers.playwright.command).toBe("npx")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("does not double-nest when output root is .kiro", async () => {
|
|
103
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-home-"))
|
|
104
|
+
const kiroRoot = path.join(tempRoot, ".kiro")
|
|
105
|
+
const bundle: KiroBundle = {
|
|
106
|
+
...emptyBundle,
|
|
107
|
+
agents: [
|
|
108
|
+
{
|
|
109
|
+
name: "reviewer",
|
|
110
|
+
config: {
|
|
111
|
+
name: "reviewer",
|
|
112
|
+
description: "A reviewer",
|
|
113
|
+
prompt: "file://./prompts/reviewer.md",
|
|
114
|
+
tools: ["*"],
|
|
115
|
+
resources: [],
|
|
116
|
+
includeMcpJson: true,
|
|
117
|
+
},
|
|
118
|
+
promptContent: "Review content.",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await writeKiroBundle(kiroRoot, bundle)
|
|
124
|
+
|
|
125
|
+
expect(await exists(path.join(kiroRoot, "agents", "reviewer.json"))).toBe(true)
|
|
126
|
+
// Should NOT double-nest under .kiro/.kiro
|
|
127
|
+
expect(await exists(path.join(kiroRoot, ".kiro"))).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("handles empty bundles gracefully", async () => {
|
|
131
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-empty-"))
|
|
132
|
+
|
|
133
|
+
await writeKiroBundle(tempRoot, emptyBundle)
|
|
134
|
+
expect(await exists(tempRoot)).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("backs up existing mcp.json before overwrite", async () => {
|
|
138
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-backup-"))
|
|
139
|
+
const kiroRoot = path.join(tempRoot, ".kiro")
|
|
140
|
+
const settingsDir = path.join(kiroRoot, "settings")
|
|
141
|
+
await fs.mkdir(settingsDir, { recursive: true })
|
|
142
|
+
|
|
143
|
+
// Write existing mcp.json
|
|
144
|
+
const mcpPath = path.join(settingsDir, "mcp.json")
|
|
145
|
+
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } }))
|
|
146
|
+
|
|
147
|
+
const bundle: KiroBundle = {
|
|
148
|
+
...emptyBundle,
|
|
149
|
+
mcpServers: { newServer: { command: "new-cmd" } },
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await writeKiroBundle(kiroRoot, bundle)
|
|
153
|
+
|
|
154
|
+
// New mcp.json should have the new content
|
|
155
|
+
const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8"))
|
|
156
|
+
expect(newContent.mcpServers.newServer.command).toBe("new-cmd")
|
|
157
|
+
|
|
158
|
+
// A backup file should exist
|
|
159
|
+
const files = await fs.readdir(settingsDir)
|
|
160
|
+
const backupFiles = files.filter((f) => f.startsWith("mcp.json.bak."))
|
|
161
|
+
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test("merges mcpServers into existing mcp.json without clobbering other keys", async () => {
|
|
165
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-merge-"))
|
|
166
|
+
const kiroRoot = path.join(tempRoot, ".kiro")
|
|
167
|
+
const settingsDir = path.join(kiroRoot, "settings")
|
|
168
|
+
await fs.mkdir(settingsDir, { recursive: true })
|
|
169
|
+
|
|
170
|
+
// Write existing mcp.json with other keys
|
|
171
|
+
const mcpPath = path.join(settingsDir, "mcp.json")
|
|
172
|
+
await fs.writeFile(mcpPath, JSON.stringify({
|
|
173
|
+
customKey: "preserve-me",
|
|
174
|
+
mcpServers: { old: { command: "old-cmd" } },
|
|
175
|
+
}))
|
|
176
|
+
|
|
177
|
+
const bundle: KiroBundle = {
|
|
178
|
+
...emptyBundle,
|
|
179
|
+
mcpServers: { newServer: { command: "new-cmd" } },
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await writeKiroBundle(kiroRoot, bundle)
|
|
183
|
+
|
|
184
|
+
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
|
|
185
|
+
expect(content.customKey).toBe("preserve-me")
|
|
186
|
+
expect(content.mcpServers.old.command).toBe("old-cmd")
|
|
187
|
+
expect(content.mcpServers.newServer.command).toBe("new-cmd")
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("mcp.json fresh write when no existing file", async () => {
|
|
191
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-fresh-"))
|
|
192
|
+
const bundle: KiroBundle = {
|
|
193
|
+
...emptyBundle,
|
|
194
|
+
mcpServers: { myServer: { command: "my-cmd", args: ["--flag"] } },
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await writeKiroBundle(tempRoot, bundle)
|
|
198
|
+
|
|
199
|
+
const mcpPath = path.join(tempRoot, ".kiro", "settings", "mcp.json")
|
|
200
|
+
expect(await exists(mcpPath)).toBe(true)
|
|
201
|
+
const content = JSON.parse(await fs.readFile(mcpPath, "utf8"))
|
|
202
|
+
expect(content.mcpServers.myServer.command).toBe("my-cmd")
|
|
203
|
+
expect(content.mcpServers.myServer.args).toEqual(["--flag"])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test("agent JSON files are valid JSON with expected fields", async () => {
|
|
207
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-json-"))
|
|
208
|
+
const bundle: KiroBundle = {
|
|
209
|
+
...emptyBundle,
|
|
210
|
+
agents: [
|
|
211
|
+
{
|
|
212
|
+
name: "test-agent",
|
|
213
|
+
config: {
|
|
214
|
+
name: "test-agent",
|
|
215
|
+
description: "Test agent",
|
|
216
|
+
prompt: "file://./prompts/test-agent.md",
|
|
217
|
+
tools: ["*"],
|
|
218
|
+
resources: ["file://.kiro/steering/**/*.md"],
|
|
219
|
+
includeMcpJson: true,
|
|
220
|
+
welcomeMessage: "Hello from test-agent.",
|
|
221
|
+
},
|
|
222
|
+
promptContent: "Do test things.",
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await writeKiroBundle(tempRoot, bundle)
|
|
228
|
+
|
|
229
|
+
const configPath = path.join(tempRoot, ".kiro", "agents", "test-agent.json")
|
|
230
|
+
const raw = await fs.readFile(configPath, "utf8")
|
|
231
|
+
const parsed = JSON.parse(raw) // Should not throw
|
|
232
|
+
expect(parsed.name).toBe("test-agent")
|
|
233
|
+
expect(parsed.prompt).toBe("file://./prompts/test-agent.md")
|
|
234
|
+
expect(parsed.tools).toEqual(["*"])
|
|
235
|
+
expect(parsed.includeMcpJson).toBe(true)
|
|
236
|
+
expect(parsed.welcomeMessage).toBe("Hello from test-agent.")
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test("path traversal attempt in skill name is rejected", async () => {
|
|
240
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-traversal-"))
|
|
241
|
+
const bundle: KiroBundle = {
|
|
242
|
+
...emptyBundle,
|
|
243
|
+
generatedSkills: [
|
|
244
|
+
{ name: "../escape", content: "Malicious content" },
|
|
245
|
+
],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
expect(writeKiroBundle(tempRoot, bundle)).rejects.toThrow("unsafe path")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test("path traversal in agent name is rejected", async () => {
|
|
252
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-traversal2-"))
|
|
253
|
+
const bundle: KiroBundle = {
|
|
254
|
+
...emptyBundle,
|
|
255
|
+
agents: [
|
|
256
|
+
{
|
|
257
|
+
name: "../escape",
|
|
258
|
+
config: {
|
|
259
|
+
name: "../escape",
|
|
260
|
+
description: "Malicious",
|
|
261
|
+
prompt: "file://./prompts/../escape.md",
|
|
262
|
+
tools: ["*"],
|
|
263
|
+
resources: [],
|
|
264
|
+
includeMcpJson: true,
|
|
265
|
+
},
|
|
266
|
+
promptContent: "Bad.",
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
expect(writeKiroBundle(tempRoot, bundle)).rejects.toThrow("unsafe path")
|
|
272
|
+
})
|
|
273
|
+
})
|