@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
|
@@ -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 {
|
|
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
|
|
11
|
+
export type ClaudeToCopilotOptions = ClaudeToOpenCodeOptions
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
const COPILOT_BODY_CHAR_LIMIT = 30_000
|
|
14
|
+
|
|
15
|
+
export function convertClaudeToCopilot(
|
|
9
16
|
plugin: ClaudePlugin,
|
|
10
|
-
_options:
|
|
11
|
-
):
|
|
12
|
-
const
|
|
13
|
-
const
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
37
|
+
const mcpConfig = convertMcpServers(plugin.mcpServers)
|
|
23
38
|
|
|
24
39
|
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
|
|
25
|
-
console.warn("Warning:
|
|
40
|
+
console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.")
|
|
26
41
|
}
|
|
27
42
|
|
|
28
|
-
return {
|
|
43
|
+
return { agents, generatedSkills, skillDirs, mcpConfig }
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
function
|
|
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
|
-
|
|
52
|
+
tools: ["*"],
|
|
53
|
+
infer: true,
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
85
|
+
const frontmatter: Record<string, unknown> = {
|
|
86
|
+
name,
|
|
87
|
+
}
|
|
58
88
|
if (command.description) {
|
|
59
|
-
|
|
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 =
|
|
98
|
+
const transformedBody = transformContentForCopilot(command.body.trim())
|
|
67
99
|
sections.push(transformedBody)
|
|
68
100
|
|
|
69
|
-
const
|
|
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 (
|
|
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
|
|
97
|
-
return `/${
|
|
121
|
+
const normalized = flattenCommandName(commandName)
|
|
122
|
+
return `/${normalized}`
|
|
98
123
|
})
|
|
99
124
|
|
|
100
|
-
// 3. Rewrite .claude/ paths to .
|
|
125
|
+
// 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/
|
|
101
126
|
result = result
|
|
102
|
-
.replace(/~\/\.claude\//g, "~/.
|
|
103
|
-
.replace(/\.claude\//g, ".
|
|
127
|
+
.replace(/~\/\.claude\//g, "~/.copilot/")
|
|
128
|
+
.replace(/\.claude\//g, ".github/")
|
|
104
129
|
|
|
105
130
|
// 4. Transform @agent-name references
|
|
106
|
-
const agentRefPattern =
|
|
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)}
|
|
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,
|
|
142
|
+
): Record<string, CopilotMcpServer> | undefined {
|
|
117
143
|
if (!servers || Object.keys(servers).length === 0) return undefined
|
|
118
144
|
|
|
119
|
-
const result: Record<string,
|
|
145
|
+
const result: Record<string, CopilotMcpServer> = {}
|
|
120
146
|
for (const [name, server] of Object.entries(servers)) {
|
|
121
|
-
const entry:
|
|
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
|
-
|
|
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
|
|
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
|
|
16
|
-
mcpServers: Record<string,
|
|
17
|
+
type CopilotMcpConfig = {
|
|
18
|
+
mcpServers: Record<string, CopilotMcpServer>
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export async function
|
|
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 =
|
|
39
|
-
const merged:
|
|
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<
|
|
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<
|
|
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
|
|
63
|
+
function convertMcpForCopilot(
|
|
62
64
|
servers: Record<string, ClaudeMcpServer>,
|
|
63
|
-
): Record<string,
|
|
64
|
-
const result: Record<string,
|
|
65
|
+
): Record<string, CopilotMcpServer> {
|
|
66
|
+
const result: Record<string, CopilotMcpServer> = {}
|
|
65
67
|
for (const [name, server] of Object.entries(servers)) {
|
|
66
|
-
const entry:
|
|
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
|
+
}
|