@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.
Files changed (40) hide show
  1. package/.cursor-plugin/marketplace.json +25 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +18 -8
  4. package/bun.lock +1 -0
  5. package/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md +117 -0
  6. package/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md +30 -0
  7. package/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md +328 -0
  8. package/docs/specs/copilot.md +122 -0
  9. package/docs/specs/kiro.md +171 -0
  10. package/package.json +1 -1
  11. package/plugins/coding-tutor/.cursor-plugin/plugin.json +21 -0
  12. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  13. package/plugins/compound-engineering/.cursor-plugin/plugin.json +31 -0
  14. package/plugins/compound-engineering/.mcp.json +8 -0
  15. package/plugins/compound-engineering/CHANGELOG.md +10 -0
  16. package/plugins/compound-engineering/commands/lfg.md +3 -3
  17. package/plugins/compound-engineering/commands/slfg.md +2 -2
  18. package/plugins/compound-engineering/commands/workflows/plan.md +15 -1
  19. package/src/commands/convert.ts +2 -1
  20. package/src/commands/install.ts +9 -1
  21. package/src/commands/sync.ts +8 -8
  22. package/src/converters/{claude-to-cursor.ts → claude-to-copilot.ts} +93 -49
  23. package/src/converters/claude-to-kiro.ts +262 -0
  24. package/src/sync/{cursor.ts → copilot.ts} +36 -14
  25. package/src/targets/copilot.ts +48 -0
  26. package/src/targets/index.ts +18 -9
  27. package/src/targets/kiro.ts +122 -0
  28. package/src/types/copilot.ts +31 -0
  29. package/src/types/kiro.ts +44 -0
  30. package/src/utils/frontmatter.ts +1 -1
  31. package/tests/copilot-converter.test.ts +467 -0
  32. package/tests/copilot-writer.test.ts +189 -0
  33. package/tests/kiro-converter.test.ts +381 -0
  34. package/tests/kiro-writer.test.ts +273 -0
  35. package/tests/sync-copilot.test.ts +148 -0
  36. package/src/targets/cursor.ts +0 -48
  37. package/src/types/cursor.ts +0 -29
  38. package/tests/cursor-converter.test.ts +0 -347
  39. package/tests/cursor-writer.test.ts +0 -137
  40. package/tests/sync-cursor.test.ts +0 -92
@@ -1,43 +1,63 @@
1
1
  import { formatFrontmatter } from "../utils/frontmatter"
2
2
  import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
3
- import type { CursorBundle, CursorCommand, CursorMcpServer, CursorRule } from "../types/cursor"
3
+ import type {
4
+ CopilotAgent,
5
+ CopilotBundle,
6
+ CopilotGeneratedSkill,
7
+ CopilotMcpServer,
8
+ } from "../types/copilot"
4
9
  import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
5
10
 
6
- export type ClaudeToCursorOptions = ClaudeToOpenCodeOptions
11
+ export type ClaudeToCopilotOptions = ClaudeToOpenCodeOptions
7
12
 
8
- export function convertClaudeToCursor(
13
+ const COPILOT_BODY_CHAR_LIMIT = 30_000
14
+
15
+ export function convertClaudeToCopilot(
9
16
  plugin: ClaudePlugin,
10
- _options: ClaudeToCursorOptions,
11
- ): CursorBundle {
12
- const usedRuleNames = new Set<string>()
13
- const usedCommandNames = new Set<string>()
17
+ _options: ClaudeToCopilotOptions,
18
+ ): CopilotBundle {
19
+ const usedAgentNames = new Set<string>()
20
+ const usedSkillNames = new Set<string>()
21
+
22
+ const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))
23
+
24
+ // Reserve skill names first so generated skills (from commands) don't collide
25
+ const skillDirs = plugin.skills.map((skill) => {
26
+ usedSkillNames.add(skill.name)
27
+ return {
28
+ name: skill.name,
29
+ sourceDir: skill.sourceDir,
30
+ }
31
+ })
14
32
 
15
- const rules = plugin.agents.map((agent) => convertAgentToRule(agent, usedRuleNames))
16
- const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
17
- const skillDirs = plugin.skills.map((skill) => ({
18
- name: skill.name,
19
- sourceDir: skill.sourceDir,
20
- }))
33
+ const generatedSkills = plugin.commands.map((command) =>
34
+ convertCommandToSkill(command, usedSkillNames),
35
+ )
21
36
 
22
- const mcpServers = convertMcpServers(plugin.mcpServers)
37
+ const mcpConfig = convertMcpServers(plugin.mcpServers)
23
38
 
24
39
  if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
25
- console.warn("Warning: Cursor does not support hooks. Hooks were skipped during conversion.")
40
+ console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.")
26
41
  }
27
42
 
28
- return { rules, commands, skillDirs, mcpServers }
43
+ return { agents, generatedSkills, skillDirs, mcpConfig }
29
44
  }
30
45
 
31
- function convertAgentToRule(agent: ClaudeAgent, usedNames: Set<string>): CursorRule {
46
+ function convertAgent(agent: ClaudeAgent, usedNames: Set<string>): CopilotAgent {
32
47
  const name = uniqueName(normalizeName(agent.name), usedNames)
33
48
  const description = agent.description ?? `Converted from Claude agent ${agent.name}`
34
49
 
35
50
  const frontmatter: Record<string, unknown> = {
36
51
  description,
37
- alwaysApply: false,
52
+ tools: ["*"],
53
+ infer: true,
38
54
  }
39
55
 
40
- let body = transformContentForCursor(agent.body.trim())
56
+ if (agent.model) {
57
+ frontmatter.model = agent.model
58
+ }
59
+
60
+ let body = transformContentForCopilot(agent.body.trim())
41
61
  if (agent.capabilities && agent.capabilities.length > 0) {
42
62
  const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
43
63
  body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
@@ -46,39 +66,44 @@ function convertAgentToRule(agent: ClaudeAgent, usedNames: Set<string>): CursorR
46
66
  body = `Instructions converted from the ${agent.name} agent.`
47
67
  }
48
68
 
69
+ if (body.length > COPILOT_BODY_CHAR_LIMIT) {
70
+ console.warn(
71
+ `Warning: Agent "${agent.name}" body exceeds ${COPILOT_BODY_CHAR_LIMIT} characters (${body.length}). Copilot may truncate it.`,
72
+ )
73
+ }
74
+
49
75
  const content = formatFrontmatter(frontmatter, body)
50
76
  return { name, content }
51
77
  }
52
78
 
53
- function convertCommand(command: ClaudeCommand, usedNames: Set<string>): CursorCommand {
79
+ function convertCommandToSkill(
80
+ command: ClaudeCommand,
81
+ usedNames: Set<string>,
82
+ ): CopilotGeneratedSkill {
54
83
  const name = uniqueName(flattenCommandName(command.name), usedNames)
55
84
 
56
- const sections: string[] = []
57
-
85
+ const frontmatter: Record<string, unknown> = {
86
+ name,
87
+ }
58
88
  if (command.description) {
59
- sections.push(`<!-- ${command.description} -->`)
89
+ frontmatter.description = command.description
60
90
  }
61
91
 
92
+ const sections: string[] = []
93
+
62
94
  if (command.argumentHint) {
63
95
  sections.push(`## Arguments\n${command.argumentHint}`)
64
96
  }
65
97
 
66
- const transformedBody = transformContentForCursor(command.body.trim())
98
+ const transformedBody = transformContentForCopilot(command.body.trim())
67
99
  sections.push(transformedBody)
68
100
 
69
- const content = sections.filter(Boolean).join("\n\n").trim()
101
+ const body = sections.filter(Boolean).join("\n\n").trim()
102
+ const content = formatFrontmatter(frontmatter, body)
70
103
  return { name, content }
71
104
  }
72
105
 
73
- /**
74
- * Transform Claude Code content to Cursor-compatible content.
75
- *
76
- * 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args
77
- * 2. Slash commands: /workflows:plan -> /plan (flatten namespace)
78
- * 3. Path rewriting: .claude/ -> .cursor/
79
- * 4. Agent references: @agent-name -> the agent-name rule
80
- */
81
- export function transformContentForCursor(body: string): string {
106
+ export function transformContentForCopilot(body: string): string {
82
107
  let result = body
83
108
 
84
109
  // 1. Transform Task agent calls
@@ -88,24 +113,25 @@ export function transformContentForCursor(body: string): string {
88
113
  return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
89
114
  })
90
115
 
91
- // 2. Transform slash command references (flatten namespaces)
116
+ // 2. Transform slash command references (replace colons with hyphens)
92
117
  const slashCommandPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
93
118
  result = result.replace(slashCommandPattern, (match, commandName: string) => {
94
119
  if (commandName.includes("/")) return match
95
120
  if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match
96
- const flattened = flattenCommandName(commandName)
97
- return `/${flattened}`
121
+ const normalized = flattenCommandName(commandName)
122
+ return `/${normalized}`
98
123
  })
99
124
 
100
- // 3. Rewrite .claude/ paths to .cursor/
125
+ // 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/
101
126
  result = result
102
- .replace(/~\/\.claude\//g, "~/.cursor/")
103
- .replace(/\.claude\//g, ".cursor/")
127
+ .replace(/~\/\.claude\//g, "~/.copilot/")
128
+ .replace(/\.claude\//g, ".github/")
104
129
 
105
130
  // 4. Transform @agent-name references
106
- const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
131
+ const agentRefPattern =
132
+ /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
107
133
  result = result.replace(agentRefPattern, (_match, agentName: string) => {
108
- return `the ${normalizeName(agentName)} rule`
134
+ return `the ${normalizeName(agentName)} agent`
109
135
  })
110
136
 
111
137
  return result
@@ -113,29 +139,47 @@ export function transformContentForCursor(body: string): string {
113
139
 
114
140
  function convertMcpServers(
115
141
  servers?: Record<string, ClaudeMcpServer>,
116
- ): Record<string, CursorMcpServer> | undefined {
142
+ ): Record<string, CopilotMcpServer> | undefined {
117
143
  if (!servers || Object.keys(servers).length === 0) return undefined
118
144
 
119
- const result: Record<string, CursorMcpServer> = {}
145
+ const result: Record<string, CopilotMcpServer> = {}
120
146
  for (const [name, server] of Object.entries(servers)) {
121
- const entry: CursorMcpServer = {}
147
+ const entry: CopilotMcpServer = {
148
+ type: server.command ? "local" : "sse",
149
+ tools: ["*"],
150
+ }
151
+
122
152
  if (server.command) {
123
153
  entry.command = server.command
124
154
  if (server.args && server.args.length > 0) entry.args = server.args
125
- if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
126
155
  } else if (server.url) {
127
156
  entry.url = server.url
128
157
  if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
129
158
  }
159
+
160
+ if (server.env && Object.keys(server.env).length > 0) {
161
+ entry.env = prefixEnvVars(server.env)
162
+ }
163
+
130
164
  result[name] = entry
131
165
  }
132
166
  return result
133
167
  }
134
168
 
169
+ function prefixEnvVars(env: Record<string, string>): Record<string, string> {
170
+ const result: Record<string, string> = {}
171
+ for (const [key, value] of Object.entries(env)) {
172
+ if (key.startsWith("COPILOT_MCP_")) {
173
+ result[key] = value
174
+ } else {
175
+ result[`COPILOT_MCP_${key}`] = value
176
+ }
177
+ }
178
+ return result
179
+ }
180
+
135
181
  function flattenCommandName(name: string): string {
136
- const colonIndex = name.lastIndexOf(":")
137
- const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name
138
- return normalizeName(base)
182
+ return normalizeName(name)
139
183
  }
140
184
 
141
185
  function normalizeName(value: string): string {
@@ -0,0 +1,262 @@
1
+ import { readFileSync, existsSync } from "fs"
2
+ import path from "path"
3
+ import { formatFrontmatter } from "../utils/frontmatter"
4
+ import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
5
+ import type {
6
+ KiroAgent,
7
+ KiroAgentConfig,
8
+ KiroBundle,
9
+ KiroMcpServer,
10
+ KiroSkill,
11
+ KiroSteeringFile,
12
+ } from "../types/kiro"
13
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
14
+
15
+ export type ClaudeToKiroOptions = ClaudeToOpenCodeOptions
16
+
17
+ const KIRO_SKILL_NAME_MAX_LENGTH = 64
18
+ const KIRO_SKILL_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
19
+ const KIRO_DESCRIPTION_MAX_LENGTH = 1024
20
+
21
+ const CLAUDE_TO_KIRO_TOOLS: Record<string, string> = {
22
+ Bash: "shell",
23
+ Write: "write",
24
+ Read: "read",
25
+ Edit: "write", // NOTE: Kiro write is full-file, not surgical edit. Lossy mapping.
26
+ Glob: "glob",
27
+ Grep: "grep",
28
+ WebFetch: "web_fetch",
29
+ Task: "use_subagent",
30
+ }
31
+
32
+ export function convertClaudeToKiro(
33
+ plugin: ClaudePlugin,
34
+ _options: ClaudeToKiroOptions,
35
+ ): KiroBundle {
36
+ const usedSkillNames = new Set<string>()
37
+
38
+ // Pass-through skills are processed first — they're the source of truth
39
+ const skillDirs = plugin.skills.map((skill) => ({
40
+ name: skill.name,
41
+ sourceDir: skill.sourceDir,
42
+ }))
43
+ for (const skill of skillDirs) {
44
+ usedSkillNames.add(normalizeName(skill.name))
45
+ }
46
+
47
+ // Convert agents to Kiro custom agents
48
+ const agentNames = plugin.agents.map((a) => normalizeName(a.name))
49
+ const agents = plugin.agents.map((agent) => convertAgentToKiroAgent(agent, agentNames))
50
+
51
+ // Convert commands to skills (generated)
52
+ const generatedSkills = plugin.commands.map((command) =>
53
+ convertCommandToSkill(command, usedSkillNames, agentNames),
54
+ )
55
+
56
+ // Convert MCP servers (stdio only)
57
+ const mcpServers = convertMcpServers(plugin.mcpServers)
58
+
59
+ // Build steering files from CLAUDE.md
60
+ const steeringFiles = buildSteeringFiles(plugin, agentNames)
61
+
62
+ // Warn about hooks
63
+ if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
64
+ console.warn(
65
+ "Warning: Kiro CLI hooks use a different format (preToolUse/postToolUse inside agent configs). Hooks were skipped during conversion.",
66
+ )
67
+ }
68
+
69
+ return { agents, generatedSkills, skillDirs, steeringFiles, mcpServers }
70
+ }
71
+
72
+ function convertAgentToKiroAgent(agent: ClaudeAgent, knownAgentNames: string[]): KiroAgent {
73
+ const name = normalizeName(agent.name)
74
+ const description = sanitizeDescription(
75
+ agent.description ?? `Use this agent for ${agent.name} tasks`,
76
+ )
77
+
78
+ const config: KiroAgentConfig = {
79
+ name,
80
+ description,
81
+ prompt: `file://./prompts/${name}.md`,
82
+ tools: ["*"],
83
+ resources: [
84
+ "file://.kiro/steering/**/*.md",
85
+ "skill://.kiro/skills/**/SKILL.md",
86
+ ],
87
+ includeMcpJson: true,
88
+ welcomeMessage: `Switching to the ${name} agent. ${description}`,
89
+ }
90
+
91
+ let body = transformContentForKiro(agent.body.trim(), knownAgentNames)
92
+ if (agent.capabilities && agent.capabilities.length > 0) {
93
+ const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
94
+ body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
95
+ }
96
+ if (body.length === 0) {
97
+ body = `Instructions converted from the ${agent.name} agent.`
98
+ }
99
+
100
+ return { name, config, promptContent: body }
101
+ }
102
+
103
+ function convertCommandToSkill(
104
+ command: ClaudeCommand,
105
+ usedNames: Set<string>,
106
+ knownAgentNames: string[],
107
+ ): KiroSkill {
108
+ const rawName = normalizeName(command.name)
109
+ const name = uniqueName(rawName, usedNames)
110
+
111
+ const description = sanitizeDescription(
112
+ command.description ?? `Converted from Claude command ${command.name}`,
113
+ )
114
+
115
+ const frontmatter: Record<string, unknown> = { name, description }
116
+
117
+ let body = transformContentForKiro(command.body.trim(), knownAgentNames)
118
+ if (body.length === 0) {
119
+ body = `Instructions converted from the ${command.name} command.`
120
+ }
121
+
122
+ const content = formatFrontmatter(frontmatter, body)
123
+ return { name, content }
124
+ }
125
+
126
+ /**
127
+ * Transform Claude Code content to Kiro-compatible content.
128
+ *
129
+ * 1. Task agent calls: Task agent-name(args) -> Use the use_subagent tool ...
130
+ * 2. Path rewriting: .claude/ -> .kiro/, ~/.claude/ -> ~/.kiro/
131
+ * 3. Slash command refs: /workflows:plan -> use the workflows-plan skill
132
+ * 4. Claude tool names: Bash -> shell, Read -> read, etc.
133
+ * 5. Agent refs: @agent-name -> the agent-name agent (only for known agent names)
134
+ */
135
+ export function transformContentForKiro(body: string, knownAgentNames: string[] = []): string {
136
+ let result = body
137
+
138
+ // 1. Transform Task agent calls
139
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
140
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
141
+ return `${prefix}Use the use_subagent tool to delegate to the ${normalizeName(agentName)} agent: ${args.trim()}`
142
+ })
143
+
144
+ // 2. Rewrite .claude/ paths to .kiro/ (with word-boundary-like lookbehind)
145
+ result = result.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.kiro/")
146
+ result = result.replace(/(?<=^|\s|["'`])\.claude\//gm, ".kiro/")
147
+
148
+ // 3. Slash command refs: /command-name -> skill activation language
149
+ result = result.replace(/(?<=^|\s)`?\/([a-zA-Z][a-zA-Z0-9_:-]*)`?/gm, (_match, cmdName: string) => {
150
+ const skillName = normalizeName(cmdName)
151
+ return `the ${skillName} skill`
152
+ })
153
+
154
+ // 4. Claude tool names -> Kiro tool names
155
+ for (const [claudeTool, kiroTool] of Object.entries(CLAUDE_TO_KIRO_TOOLS)) {
156
+ // Match tool name references: "the X tool", "using X", "use X to"
157
+ const toolPattern = new RegExp(`\\b${claudeTool}\\b(?=\\s+tool|\\s+to\\s)`, "g")
158
+ result = result.replace(toolPattern, kiroTool)
159
+ }
160
+
161
+ // 5. Transform @agent-name references (only for known agent names)
162
+ if (knownAgentNames.length > 0) {
163
+ const escapedNames = knownAgentNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
164
+ const agentRefPattern = new RegExp(`@(${escapedNames.join("|")})\\b`, "g")
165
+ result = result.replace(agentRefPattern, (_match, agentName: string) => {
166
+ return `the ${normalizeName(agentName)} agent`
167
+ })
168
+ }
169
+
170
+ return result
171
+ }
172
+
173
+ function convertMcpServers(
174
+ servers?: Record<string, ClaudeMcpServer>,
175
+ ): Record<string, KiroMcpServer> {
176
+ if (!servers || Object.keys(servers).length === 0) return {}
177
+
178
+ const result: Record<string, KiroMcpServer> = {}
179
+ for (const [name, server] of Object.entries(servers)) {
180
+ if (!server.command) {
181
+ console.warn(
182
+ `Warning: MCP server "${name}" has no command (HTTP/SSE transport). Kiro only supports stdio. Skipping.`,
183
+ )
184
+ continue
185
+ }
186
+
187
+ const entry: KiroMcpServer = { command: server.command }
188
+ if (server.args && server.args.length > 0) entry.args = server.args
189
+ if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
190
+
191
+ console.log(`MCP server "${name}" will execute: ${server.command}${server.args ? " " + server.args.join(" ") : ""}`)
192
+ result[name] = entry
193
+ }
194
+ return result
195
+ }
196
+
197
+ function buildSteeringFiles(plugin: ClaudePlugin, knownAgentNames: string[]): KiroSteeringFile[] {
198
+ const claudeMdPath = path.join(plugin.root, "CLAUDE.md")
199
+ if (!existsSync(claudeMdPath)) return []
200
+
201
+ let content: string
202
+ try {
203
+ content = readFileSync(claudeMdPath, "utf8")
204
+ } catch {
205
+ return []
206
+ }
207
+
208
+ if (!content || content.trim().length === 0) return []
209
+
210
+ const transformed = transformContentForKiro(content, knownAgentNames)
211
+ return [{ name: "compound-engineering", content: transformed }]
212
+ }
213
+
214
+ function normalizeName(value: string): string {
215
+ const trimmed = value.trim()
216
+ if (!trimmed) return "item"
217
+ let normalized = trimmed
218
+ .toLowerCase()
219
+ .replace(/[\\/]+/g, "-")
220
+ .replace(/[:\s]+/g, "-")
221
+ .replace(/[^a-z0-9_-]+/g, "-")
222
+ .replace(/-+/g, "-") // Collapse consecutive hyphens (Agent Skills standard)
223
+ .replace(/^-+|-+$/g, "")
224
+
225
+ // Enforce max length (truncate at last hyphen boundary)
226
+ if (normalized.length > KIRO_SKILL_NAME_MAX_LENGTH) {
227
+ normalized = normalized.slice(0, KIRO_SKILL_NAME_MAX_LENGTH)
228
+ const lastHyphen = normalized.lastIndexOf("-")
229
+ if (lastHyphen > 0) {
230
+ normalized = normalized.slice(0, lastHyphen)
231
+ }
232
+ normalized = normalized.replace(/-+$/g, "")
233
+ }
234
+
235
+ // Ensure name starts with a letter
236
+ if (normalized.length === 0 || !/^[a-z]/.test(normalized)) {
237
+ return "item"
238
+ }
239
+
240
+ return normalized
241
+ }
242
+
243
+ function sanitizeDescription(value: string, maxLength = KIRO_DESCRIPTION_MAX_LENGTH): string {
244
+ const normalized = value.replace(/\s+/g, " ").trim()
245
+ if (normalized.length <= maxLength) return normalized
246
+ const ellipsis = "..."
247
+ return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
248
+ }
249
+
250
+ function uniqueName(base: string, used: Set<string>): string {
251
+ if (!used.has(base)) {
252
+ used.add(base)
253
+ return base
254
+ }
255
+ let index = 2
256
+ while (used.has(`${base}-${index}`)) {
257
+ index += 1
258
+ }
259
+ const name = `${base}-${index}`
260
+ used.add(name)
261
+ return name
262
+ }
@@ -4,19 +4,21 @@ import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
4
  import type { ClaudeMcpServer } from "../types/claude"
5
5
  import { forceSymlink, isValidSkillName } from "../utils/symlink"
6
6
 
7
- type CursorMcpServer = {
7
+ type CopilotMcpServer = {
8
+ type: string
8
9
  command?: string
9
10
  args?: string[]
10
11
  url?: string
12
+ tools: string[]
11
13
  env?: Record<string, string>
12
14
  headers?: Record<string, string>
13
15
  }
14
16
 
15
- type CursorMcpConfig = {
16
- mcpServers: Record<string, CursorMcpServer>
17
+ type CopilotMcpConfig = {
18
+ mcpServers: Record<string, CopilotMcpServer>
17
19
  }
18
20
 
19
- export async function syncToCursor(
21
+ export async function syncToCopilot(
20
22
  config: ClaudeHomeConfig,
21
23
  outputRoot: string,
22
24
  ): Promise<void> {
@@ -33,10 +35,10 @@ export async function syncToCursor(
33
35
  }
34
36
 
35
37
  if (Object.keys(config.mcpServers).length > 0) {
36
- const mcpPath = path.join(outputRoot, "mcp.json")
38
+ const mcpPath = path.join(outputRoot, "copilot-mcp-config.json")
37
39
  const existing = await readJsonSafe(mcpPath)
38
- const converted = convertMcpForCursor(config.mcpServers)
39
- const merged: CursorMcpConfig = {
40
+ const converted = convertMcpForCopilot(config.mcpServers)
41
+ const merged: CopilotMcpConfig = {
40
42
  mcpServers: {
41
43
  ...(existing.mcpServers ?? {}),
42
44
  ...converted,
@@ -46,10 +48,10 @@ export async function syncToCursor(
46
48
  }
47
49
  }
48
50
 
49
- async function readJsonSafe(filePath: string): Promise<Partial<CursorMcpConfig>> {
51
+ async function readJsonSafe(filePath: string): Promise<Partial<CopilotMcpConfig>> {
50
52
  try {
51
53
  const content = await fs.readFile(filePath, "utf-8")
52
- return JSON.parse(content) as Partial<CursorMcpConfig>
54
+ return JSON.parse(content) as Partial<CopilotMcpConfig>
53
55
  } catch (err) {
54
56
  if ((err as NodeJS.ErrnoException).code === "ENOENT") {
55
57
  return {}
@@ -58,21 +60,41 @@ async function readJsonSafe(filePath: string): Promise<Partial<CursorMcpConfig>>
58
60
  }
59
61
  }
60
62
 
61
- function convertMcpForCursor(
63
+ function convertMcpForCopilot(
62
64
  servers: Record<string, ClaudeMcpServer>,
63
- ): Record<string, CursorMcpServer> {
64
- const result: Record<string, CursorMcpServer> = {}
65
+ ): Record<string, CopilotMcpServer> {
66
+ const result: Record<string, CopilotMcpServer> = {}
65
67
  for (const [name, server] of Object.entries(servers)) {
66
- const entry: CursorMcpServer = {}
68
+ const entry: CopilotMcpServer = {
69
+ type: server.command ? "local" : "sse",
70
+ tools: ["*"],
71
+ }
72
+
67
73
  if (server.command) {
68
74
  entry.command = server.command
69
75
  if (server.args && server.args.length > 0) entry.args = server.args
70
- if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
71
76
  } else if (server.url) {
72
77
  entry.url = server.url
73
78
  if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
74
79
  }
80
+
81
+ if (server.env && Object.keys(server.env).length > 0) {
82
+ entry.env = prefixEnvVars(server.env)
83
+ }
84
+
75
85
  result[name] = entry
76
86
  }
77
87
  return result
78
88
  }
89
+
90
+ function prefixEnvVars(env: Record<string, string>): Record<string, string> {
91
+ const result: Record<string, string> = {}
92
+ for (const [key, value] of Object.entries(env)) {
93
+ if (key.startsWith("COPILOT_MCP_")) {
94
+ result[key] = value
95
+ } else {
96
+ result[`COPILOT_MCP_${key}`] = value
97
+ }
98
+ }
99
+ return result
100
+ }
@@ -0,0 +1,48 @@
1
+ import path from "path"
2
+ import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
3
+ import type { CopilotBundle } from "../types/copilot"
4
+
5
+ export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBundle): Promise<void> {
6
+ const paths = resolveCopilotPaths(outputRoot)
7
+ await ensureDir(paths.githubDir)
8
+
9
+ if (bundle.agents.length > 0) {
10
+ const agentsDir = path.join(paths.githubDir, "agents")
11
+ for (const agent of bundle.agents) {
12
+ await writeText(path.join(agentsDir, `${agent.name}.agent.md`), agent.content + "\n")
13
+ }
14
+ }
15
+
16
+ if (bundle.generatedSkills.length > 0) {
17
+ const skillsDir = path.join(paths.githubDir, "skills")
18
+ for (const skill of bundle.generatedSkills) {
19
+ await writeText(path.join(skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
20
+ }
21
+ }
22
+
23
+ if (bundle.skillDirs.length > 0) {
24
+ const skillsDir = path.join(paths.githubDir, "skills")
25
+ for (const skill of bundle.skillDirs) {
26
+ await copyDir(skill.sourceDir, path.join(skillsDir, skill.name))
27
+ }
28
+ }
29
+
30
+ if (bundle.mcpConfig && Object.keys(bundle.mcpConfig).length > 0) {
31
+ const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json")
32
+ const backupPath = await backupFile(mcpPath)
33
+ if (backupPath) {
34
+ console.log(`Backed up existing copilot-mcp-config.json to ${backupPath}`)
35
+ }
36
+ await writeJson(mcpPath, { mcpServers: bundle.mcpConfig })
37
+ }
38
+ }
39
+
40
+ function resolveCopilotPaths(outputRoot: string) {
41
+ const base = path.basename(outputRoot)
42
+ // If already pointing at .github, write directly into it
43
+ if (base === ".github") {
44
+ return { githubDir: outputRoot }
45
+ }
46
+ // Otherwise nest under .github
47
+ return { githubDir: path.join(outputRoot, ".github") }
48
+ }