@every-env/compound-plugin 0.12.0 → 2.34.2

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.github/workflows/publish.yml +20 -10
  3. package/.releaserc.json +31 -0
  4. package/AGENTS.md +1 -0
  5. package/CHANGELOG.md +34 -0
  6. package/CLAUDE.md +13 -0
  7. package/README.md +35 -2
  8. package/bun.lock +977 -0
  9. package/docs/plans/2026-03-01-fix-setup-skill-non-claude-llm-fallback-plan.md +140 -0
  10. package/docs/plans/2026-03-03-feat-sync-claude-mcp-all-supported-providers-plan.md +639 -0
  11. package/docs/solutions/adding-converter-target-providers.md +2 -1
  12. package/docs/solutions/plugin-versioning-requirements.md +4 -0
  13. package/package.json +10 -4
  14. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  15. package/plugins/compound-engineering/CHANGELOG.md +10 -0
  16. package/plugins/compound-engineering/CLAUDE.md +5 -0
  17. package/plugins/compound-engineering/skills/create-agent-skills/workflows/add-workflow.md +6 -0
  18. package/plugins/compound-engineering/skills/create-agent-skills/workflows/create-new-skill.md +6 -0
  19. package/plugins/compound-engineering/skills/setup/SKILL.md +6 -0
  20. package/src/commands/sync.ts +21 -60
  21. package/src/index.ts +2 -1
  22. package/src/parsers/claude-home.ts +55 -3
  23. package/src/sync/codex.ts +38 -62
  24. package/src/sync/commands.ts +198 -0
  25. package/src/sync/copilot.ts +14 -36
  26. package/src/sync/droid.ts +50 -9
  27. package/src/sync/gemini.ts +87 -28
  28. package/src/sync/json-config.ts +47 -0
  29. package/src/sync/kiro.ts +49 -0
  30. package/src/sync/mcp-transports.ts +19 -0
  31. package/src/sync/openclaw.ts +18 -0
  32. package/src/sync/opencode.ts +10 -30
  33. package/src/sync/pi.ts +12 -36
  34. package/src/sync/qwen.ts +66 -0
  35. package/src/sync/registry.ts +141 -0
  36. package/src/sync/skills.ts +21 -0
  37. package/src/sync/windsurf.ts +59 -0
  38. package/src/types/kiro.ts +3 -1
  39. package/src/types/qwen.ts +3 -0
  40. package/src/types/windsurf.ts +1 -0
  41. package/src/utils/codex-agents.ts +1 -1
  42. package/src/utils/detect-tools.ts +4 -13
  43. package/src/utils/files.ts +7 -0
  44. package/src/utils/symlink.ts +4 -6
  45. package/tests/claude-home.test.ts +46 -0
  46. package/tests/cli.test.ts +102 -0
  47. package/tests/detect-tools.test.ts +30 -7
  48. package/tests/sync-codex.test.ts +64 -0
  49. package/tests/sync-copilot.test.ts +60 -4
  50. package/tests/sync-droid.test.ts +44 -4
  51. package/tests/sync-gemini.test.ts +54 -0
  52. package/tests/sync-kiro.test.ts +83 -0
  53. package/tests/sync-openclaw.test.ts +51 -0
  54. package/tests/sync-qwen.test.ts +75 -0
  55. package/tests/sync-windsurf.test.ts +89 -0
@@ -1,11 +1,13 @@
1
- import fs from "fs/promises"
2
1
  import path from "path"
3
2
  import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
3
  import type { ClaudeMcpServer } from "../types/claude"
5
- import { forceSymlink, isValidSkillName } from "../utils/symlink"
4
+ import { syncCopilotCommands } from "./commands"
5
+ import { mergeJsonConfigAtKey } from "./json-config"
6
+ import { hasExplicitSseTransport } from "./mcp-transports"
7
+ import { syncSkills } from "./skills"
6
8
 
7
9
  type CopilotMcpServer = {
8
- type: string
10
+ type: "local" | "http" | "sse"
9
11
  command?: string
10
12
  args?: string[]
11
13
  url?: string
@@ -22,41 +24,17 @@ export async function syncToCopilot(
22
24
  config: ClaudeHomeConfig,
23
25
  outputRoot: string,
24
26
  ): Promise<void> {
25
- const skillsDir = path.join(outputRoot, "skills")
26
- await fs.mkdir(skillsDir, { recursive: true })
27
-
28
- for (const skill of config.skills) {
29
- if (!isValidSkillName(skill.name)) {
30
- console.warn(`Skipping skill with invalid name: ${skill.name}`)
31
- continue
32
- }
33
- const target = path.join(skillsDir, skill.name)
34
- await forceSymlink(skill.sourceDir, target)
35
- }
27
+ await syncSkills(config.skills, path.join(outputRoot, "skills"))
28
+ await syncCopilotCommands(config, outputRoot)
36
29
 
37
30
  if (Object.keys(config.mcpServers).length > 0) {
38
- const mcpPath = path.join(outputRoot, "copilot-mcp-config.json")
39
- const existing = await readJsonSafe(mcpPath)
31
+ const mcpPath = path.join(outputRoot, "mcp-config.json")
40
32
  const converted = convertMcpForCopilot(config.mcpServers)
41
- const merged: CopilotMcpConfig = {
42
- mcpServers: {
43
- ...(existing.mcpServers ?? {}),
44
- ...converted,
45
- },
46
- }
47
- await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
48
- }
49
- }
50
-
51
- async function readJsonSafe(filePath: string): Promise<Partial<CopilotMcpConfig>> {
52
- try {
53
- const content = await fs.readFile(filePath, "utf-8")
54
- return JSON.parse(content) as Partial<CopilotMcpConfig>
55
- } catch (err) {
56
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
57
- return {}
58
- }
59
- throw err
33
+ await mergeJsonConfigAtKey({
34
+ configPath: mcpPath,
35
+ key: "mcpServers",
36
+ incoming: converted,
37
+ })
60
38
  }
61
39
  }
62
40
 
@@ -66,7 +44,7 @@ function convertMcpForCopilot(
66
44
  const result: Record<string, CopilotMcpServer> = {}
67
45
  for (const [name, server] of Object.entries(servers)) {
68
46
  const entry: CopilotMcpServer = {
69
- type: server.command ? "local" : "sse",
47
+ type: server.command ? "local" : hasExplicitSseTransport(server) ? "sse" : "http",
70
48
  tools: ["*"],
71
49
  }
72
50
 
package/src/sync/droid.ts CHANGED
@@ -1,21 +1,62 @@
1
- import fs from "fs/promises"
2
1
  import path from "path"
3
2
  import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
- import { forceSymlink, isValidSkillName } from "../utils/symlink"
3
+ import type { ClaudeMcpServer } from "../types/claude"
4
+ import { syncDroidCommands } from "./commands"
5
+ import { mergeJsonConfigAtKey } from "./json-config"
6
+ import { syncSkills } from "./skills"
7
+
8
+ type DroidMcpServer = {
9
+ type: "stdio" | "http"
10
+ command?: string
11
+ args?: string[]
12
+ env?: Record<string, string>
13
+ url?: string
14
+ headers?: Record<string, string>
15
+ disabled: boolean
16
+ }
5
17
 
6
18
  export async function syncToDroid(
7
19
  config: ClaudeHomeConfig,
8
20
  outputRoot: string,
9
21
  ): Promise<void> {
10
- const skillsDir = path.join(outputRoot, "skills")
11
- await fs.mkdir(skillsDir, { recursive: true })
22
+ await syncSkills(config.skills, path.join(outputRoot, "skills"))
23
+ await syncDroidCommands(config, outputRoot)
24
+
25
+ if (Object.keys(config.mcpServers).length > 0) {
26
+ await mergeJsonConfigAtKey({
27
+ configPath: path.join(outputRoot, "mcp.json"),
28
+ key: "mcpServers",
29
+ incoming: convertMcpForDroid(config.mcpServers),
30
+ })
31
+ }
32
+ }
12
33
 
13
- for (const skill of config.skills) {
14
- if (!isValidSkillName(skill.name)) {
15
- console.warn(`Skipping skill with invalid name: ${skill.name}`)
34
+ function convertMcpForDroid(
35
+ servers: Record<string, ClaudeMcpServer>,
36
+ ): Record<string, DroidMcpServer> {
37
+ const result: Record<string, DroidMcpServer> = {}
38
+
39
+ for (const [name, server] of Object.entries(servers)) {
40
+ if (server.command) {
41
+ result[name] = {
42
+ type: "stdio",
43
+ command: server.command,
44
+ args: server.args,
45
+ env: server.env,
46
+ disabled: false,
47
+ }
16
48
  continue
17
49
  }
18
- const target = path.join(skillsDir, skill.name)
19
- await forceSymlink(skill.sourceDir, target)
50
+
51
+ if (server.url) {
52
+ result[name] = {
53
+ type: "http",
54
+ url: server.url,
55
+ headers: server.headers,
56
+ disabled: false,
57
+ }
58
+ }
20
59
  }
60
+
61
+ return result
21
62
  }
@@ -2,7 +2,9 @@ import fs from "fs/promises"
2
2
  import path from "path"
3
3
  import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
4
  import type { ClaudeMcpServer } from "../types/claude"
5
- import { forceSymlink, isValidSkillName } from "../utils/symlink"
5
+ import { syncGeminiCommands } from "./commands"
6
+ import { mergeJsonConfigAtKey } from "./json-config"
7
+ import { syncSkills } from "./skills"
6
8
 
7
9
  type GeminiMcpServer = {
8
10
  command?: string
@@ -16,43 +18,100 @@ export async function syncToGemini(
16
18
  config: ClaudeHomeConfig,
17
19
  outputRoot: string,
18
20
  ): Promise<void> {
19
- const skillsDir = path.join(outputRoot, "skills")
20
- await fs.mkdir(skillsDir, { recursive: true })
21
-
22
- for (const skill of config.skills) {
23
- if (!isValidSkillName(skill.name)) {
24
- console.warn(`Skipping skill with invalid name: ${skill.name}`)
25
- continue
26
- }
27
- const target = path.join(skillsDir, skill.name)
28
- await forceSymlink(skill.sourceDir, target)
29
- }
21
+ await syncGeminiSkills(config.skills, outputRoot)
22
+ await syncGeminiCommands(config, outputRoot)
30
23
 
31
24
  if (Object.keys(config.mcpServers).length > 0) {
32
25
  const settingsPath = path.join(outputRoot, "settings.json")
33
- const existing = await readJsonSafe(settingsPath)
34
26
  const converted = convertMcpForGemini(config.mcpServers)
35
- const existingMcp =
36
- existing.mcpServers && typeof existing.mcpServers === "object"
37
- ? (existing.mcpServers as Record<string, unknown>)
38
- : {}
39
- const merged = {
40
- ...existing,
41
- mcpServers: { ...existingMcp, ...converted },
27
+ await mergeJsonConfigAtKey({
28
+ configPath: settingsPath,
29
+ key: "mcpServers",
30
+ incoming: converted,
31
+ })
32
+ }
33
+ }
34
+
35
+ async function syncGeminiSkills(
36
+ skills: ClaudeHomeConfig["skills"],
37
+ outputRoot: string,
38
+ ): Promise<void> {
39
+ const skillsDir = path.join(outputRoot, "skills")
40
+ const sharedSkillsDir = getGeminiSharedSkillsDir(outputRoot)
41
+
42
+ if (!sharedSkillsDir) {
43
+ await syncSkills(skills, skillsDir)
44
+ return
45
+ }
46
+
47
+ const canonicalSharedSkillsDir = await canonicalizePath(sharedSkillsDir)
48
+ const mirroredSkills: ClaudeHomeConfig["skills"] = []
49
+ const directSkills: ClaudeHomeConfig["skills"] = []
50
+
51
+ for (const skill of skills) {
52
+ if (await isWithinDir(skill.sourceDir, canonicalSharedSkillsDir)) {
53
+ mirroredSkills.push(skill)
54
+ } else {
55
+ directSkills.push(skill)
42
56
  }
43
- await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
44
57
  }
58
+
59
+ await removeGeminiMirrorConflicts(mirroredSkills, skillsDir, canonicalSharedSkillsDir)
60
+ await syncSkills(directSkills, skillsDir)
45
61
  }
46
62
 
47
- async function readJsonSafe(filePath: string): Promise<Record<string, unknown>> {
63
+ function getGeminiSharedSkillsDir(outputRoot: string): string | null {
64
+ if (path.basename(outputRoot) !== ".gemini") return null
65
+ return path.join(path.dirname(outputRoot), ".agents", "skills")
66
+ }
67
+
68
+ async function canonicalizePath(targetPath: string): Promise<string> {
48
69
  try {
49
- const content = await fs.readFile(filePath, "utf-8")
50
- return JSON.parse(content) as Record<string, unknown>
51
- } catch (err) {
52
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
53
- return {}
70
+ return await fs.realpath(targetPath)
71
+ } catch {
72
+ return path.resolve(targetPath)
73
+ }
74
+ }
75
+
76
+ async function isWithinDir(candidate: string, canonicalParentDir: string): Promise<boolean> {
77
+ const resolvedCandidate = await canonicalizePath(candidate)
78
+ return resolvedCandidate === canonicalParentDir
79
+ || resolvedCandidate.startsWith(`${canonicalParentDir}${path.sep}`)
80
+ }
81
+
82
+ async function removeGeminiMirrorConflicts(
83
+ skills: ClaudeHomeConfig["skills"],
84
+ skillsDir: string,
85
+ sharedSkillsDir: string,
86
+ ): Promise<void> {
87
+ for (const skill of skills) {
88
+ const duplicatePath = path.join(skillsDir, skill.name)
89
+
90
+ let stat
91
+ try {
92
+ stat = await fs.lstat(duplicatePath)
93
+ } catch (error) {
94
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
95
+ continue
96
+ }
97
+ throw error
98
+ }
99
+
100
+ if (!stat.isSymbolicLink()) {
101
+ continue
102
+ }
103
+
104
+ let resolvedTarget: string
105
+ try {
106
+ resolvedTarget = await canonicalizePath(duplicatePath)
107
+ } catch {
108
+ continue
109
+ }
110
+
111
+ if (resolvedTarget === await canonicalizePath(skill.sourceDir)
112
+ || await isWithinDir(resolvedTarget, sharedSkillsDir)) {
113
+ await fs.unlink(duplicatePath)
54
114
  }
55
- throw err
56
115
  }
57
116
  }
58
117
 
@@ -0,0 +1,47 @@
1
+ import path from "path"
2
+ import { pathExists, readJson, writeJsonSecure } from "../utils/files"
3
+
4
+ type JsonObject = Record<string, unknown>
5
+
6
+ function isJsonObject(value: unknown): value is JsonObject {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value)
8
+ }
9
+
10
+ export async function mergeJsonConfigAtKey(options: {
11
+ configPath: string
12
+ key: string
13
+ incoming: Record<string, unknown>
14
+ }): Promise<void> {
15
+ const { configPath, key, incoming } = options
16
+ const existing = await readJsonObjectSafe(configPath)
17
+ const existingEntries = isJsonObject(existing[key]) ? existing[key] : {}
18
+ const merged = {
19
+ ...existing,
20
+ [key]: {
21
+ ...existingEntries,
22
+ ...incoming,
23
+ },
24
+ }
25
+
26
+ await writeJsonSecure(configPath, merged)
27
+ }
28
+
29
+ async function readJsonObjectSafe(configPath: string): Promise<JsonObject> {
30
+ if (!(await pathExists(configPath))) {
31
+ return {}
32
+ }
33
+
34
+ try {
35
+ const parsed = await readJson<unknown>(configPath)
36
+ if (isJsonObject(parsed)) {
37
+ return parsed
38
+ }
39
+ } catch {
40
+ // Fall through to warning and replacement.
41
+ }
42
+
43
+ console.warn(
44
+ `Warning: existing ${path.basename(configPath)} could not be parsed and will be replaced.`,
45
+ )
46
+ return {}
47
+ }
@@ -0,0 +1,49 @@
1
+ import path from "path"
2
+ import type { ClaudeHomeConfig } from "../parsers/claude-home"
3
+ import type { ClaudeMcpServer } from "../types/claude"
4
+ import type { KiroMcpServer } from "../types/kiro"
5
+ import { syncKiroCommands } from "./commands"
6
+ import { mergeJsonConfigAtKey } from "./json-config"
7
+ import { syncSkills } from "./skills"
8
+
9
+ export async function syncToKiro(
10
+ config: ClaudeHomeConfig,
11
+ outputRoot: string,
12
+ ): Promise<void> {
13
+ await syncSkills(config.skills, path.join(outputRoot, "skills"))
14
+ await syncKiroCommands(config, outputRoot)
15
+
16
+ if (Object.keys(config.mcpServers).length > 0) {
17
+ await mergeJsonConfigAtKey({
18
+ configPath: path.join(outputRoot, "settings", "mcp.json"),
19
+ key: "mcpServers",
20
+ incoming: convertMcpForKiro(config.mcpServers),
21
+ })
22
+ }
23
+ }
24
+
25
+ function convertMcpForKiro(
26
+ servers: Record<string, ClaudeMcpServer>,
27
+ ): Record<string, KiroMcpServer> {
28
+ const result: Record<string, KiroMcpServer> = {}
29
+
30
+ for (const [name, server] of Object.entries(servers)) {
31
+ if (server.command) {
32
+ result[name] = {
33
+ command: server.command,
34
+ args: server.args,
35
+ env: server.env,
36
+ }
37
+ continue
38
+ }
39
+
40
+ if (server.url) {
41
+ result[name] = {
42
+ url: server.url,
43
+ headers: server.headers,
44
+ }
45
+ }
46
+ }
47
+
48
+ return result
49
+ }
@@ -0,0 +1,19 @@
1
+ import type { ClaudeMcpServer } from "../types/claude"
2
+
3
+ function getTransportType(server: ClaudeMcpServer): string {
4
+ return server.type?.toLowerCase().trim() ?? ""
5
+ }
6
+
7
+ export function hasExplicitSseTransport(server: ClaudeMcpServer): boolean {
8
+ const type = getTransportType(server)
9
+ return type.includes("sse")
10
+ }
11
+
12
+ export function hasExplicitHttpTransport(server: ClaudeMcpServer): boolean {
13
+ const type = getTransportType(server)
14
+ return type.includes("http") || type.includes("streamable")
15
+ }
16
+
17
+ export function hasExplicitRemoteTransport(server: ClaudeMcpServer): boolean {
18
+ return hasExplicitSseTransport(server) || hasExplicitHttpTransport(server)
19
+ }
@@ -0,0 +1,18 @@
1
+ import path from "path"
2
+ import type { ClaudeHomeConfig } from "../parsers/claude-home"
3
+ import { warnUnsupportedOpenClawCommands } from "./commands"
4
+ import { syncSkills } from "./skills"
5
+
6
+ export async function syncToOpenClaw(
7
+ config: ClaudeHomeConfig,
8
+ outputRoot: string,
9
+ ): Promise<void> {
10
+ await syncSkills(config.skills, path.join(outputRoot, "skills"))
11
+ warnUnsupportedOpenClawCommands(config)
12
+
13
+ if (Object.keys(config.mcpServers).length > 0) {
14
+ console.warn(
15
+ "Warning: OpenClaw MCP sync is skipped because the current official OpenClaw docs do not clearly document an MCP server config contract.",
16
+ )
17
+ }
18
+ }
@@ -1,47 +1,27 @@
1
- import fs from "fs/promises"
2
1
  import path from "path"
3
2
  import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
3
  import type { ClaudeMcpServer } from "../types/claude"
5
4
  import type { OpenCodeMcpServer } from "../types/opencode"
6
- import { forceSymlink, isValidSkillName } from "../utils/symlink"
5
+ import { syncOpenCodeCommands } from "./commands"
6
+ import { mergeJsonConfigAtKey } from "./json-config"
7
+ import { syncSkills } from "./skills"
7
8
 
8
9
  export async function syncToOpenCode(
9
10
  config: ClaudeHomeConfig,
10
11
  outputRoot: string,
11
12
  ): Promise<void> {
12
- // Ensure output directories exist
13
- const skillsDir = path.join(outputRoot, "skills")
14
- await fs.mkdir(skillsDir, { recursive: true })
15
-
16
- // Symlink skills (with validation)
17
- for (const skill of config.skills) {
18
- if (!isValidSkillName(skill.name)) {
19
- console.warn(`Skipping skill with invalid name: ${skill.name}`)
20
- continue
21
- }
22
- const target = path.join(skillsDir, skill.name)
23
- await forceSymlink(skill.sourceDir, target)
24
- }
13
+ await syncSkills(config.skills, path.join(outputRoot, "skills"))
14
+ await syncOpenCodeCommands(config, outputRoot)
25
15
 
26
16
  // Merge MCP servers into opencode.json
27
17
  if (Object.keys(config.mcpServers).length > 0) {
28
18
  const configPath = path.join(outputRoot, "opencode.json")
29
- const existing = await readJsonSafe(configPath)
30
19
  const mcpConfig = convertMcpForOpenCode(config.mcpServers)
31
- existing.mcp = { ...(existing.mcp ?? {}), ...mcpConfig }
32
- await fs.writeFile(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 })
33
- }
34
- }
35
-
36
- async function readJsonSafe(filePath: string): Promise<Record<string, unknown>> {
37
- try {
38
- const content = await fs.readFile(filePath, "utf-8")
39
- return JSON.parse(content) as Record<string, unknown>
40
- } catch (err) {
41
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
42
- return {}
43
- }
44
- throw err
20
+ await mergeJsonConfigAtKey({
21
+ configPath,
22
+ key: "mcp",
23
+ incoming: mcpConfig,
24
+ })
45
25
  }
46
26
  }
47
27
 
package/src/sync/pi.ts CHANGED
@@ -1,8 +1,10 @@
1
- import fs from "fs/promises"
2
1
  import path from "path"
3
2
  import type { ClaudeHomeConfig } from "../parsers/claude-home"
4
3
  import type { ClaudeMcpServer } from "../types/claude"
5
- import { forceSymlink, isValidSkillName } from "../utils/symlink"
4
+ import { ensureDir } from "../utils/files"
5
+ import { syncPiCommands } from "./commands"
6
+ import { mergeJsonConfigAtKey } from "./json-config"
7
+ import { syncSkills } from "./skills"
6
8
 
7
9
  type McporterServer = {
8
10
  baseUrl?: string
@@ -20,45 +22,19 @@ export async function syncToPi(
20
22
  config: ClaudeHomeConfig,
21
23
  outputRoot: string,
22
24
  ): Promise<void> {
23
- const skillsDir = path.join(outputRoot, "skills")
24
25
  const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
25
26
 
26
- await fs.mkdir(skillsDir, { recursive: true })
27
-
28
- for (const skill of config.skills) {
29
- if (!isValidSkillName(skill.name)) {
30
- console.warn(`Skipping skill with invalid name: ${skill.name}`)
31
- continue
32
- }
33
- const target = path.join(skillsDir, skill.name)
34
- await forceSymlink(skill.sourceDir, target)
35
- }
27
+ await syncSkills(config.skills, path.join(outputRoot, "skills"))
28
+ await syncPiCommands(config, outputRoot)
36
29
 
37
30
  if (Object.keys(config.mcpServers).length > 0) {
38
- await fs.mkdir(path.dirname(mcporterPath), { recursive: true })
39
-
40
- const existing = await readJsonSafe(mcporterPath)
31
+ await ensureDir(path.dirname(mcporterPath))
41
32
  const converted = convertMcpToMcporter(config.mcpServers)
42
- const merged: McporterConfig = {
43
- mcpServers: {
44
- ...(existing.mcpServers ?? {}),
45
- ...converted.mcpServers,
46
- },
47
- }
48
-
49
- await fs.writeFile(mcporterPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
50
- }
51
- }
52
-
53
- async function readJsonSafe(filePath: string): Promise<Partial<McporterConfig>> {
54
- try {
55
- const content = await fs.readFile(filePath, "utf-8")
56
- return JSON.parse(content) as Partial<McporterConfig>
57
- } catch (err) {
58
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
59
- return {}
60
- }
61
- throw err
33
+ await mergeJsonConfigAtKey({
34
+ configPath: mcporterPath,
35
+ key: "mcpServers",
36
+ incoming: converted.mcpServers,
37
+ })
62
38
  }
63
39
  }
64
40
 
@@ -0,0 +1,66 @@
1
+ import path from "path"
2
+ import type { ClaudeHomeConfig } from "../parsers/claude-home"
3
+ import type { ClaudeMcpServer } from "../types/claude"
4
+ import type { QwenMcpServer } from "../types/qwen"
5
+ import { syncQwenCommands } from "./commands"
6
+ import { mergeJsonConfigAtKey } from "./json-config"
7
+ import { hasExplicitRemoteTransport, hasExplicitSseTransport } from "./mcp-transports"
8
+ import { syncSkills } from "./skills"
9
+
10
+ export async function syncToQwen(
11
+ config: ClaudeHomeConfig,
12
+ outputRoot: string,
13
+ ): Promise<void> {
14
+ await syncSkills(config.skills, path.join(outputRoot, "skills"))
15
+ await syncQwenCommands(config, outputRoot)
16
+
17
+ if (Object.keys(config.mcpServers).length > 0) {
18
+ await mergeJsonConfigAtKey({
19
+ configPath: path.join(outputRoot, "settings.json"),
20
+ key: "mcpServers",
21
+ incoming: convertMcpForQwen(config.mcpServers),
22
+ })
23
+ }
24
+ }
25
+
26
+ function convertMcpForQwen(
27
+ servers: Record<string, ClaudeMcpServer>,
28
+ ): Record<string, QwenMcpServer> {
29
+ const result: Record<string, QwenMcpServer> = {}
30
+
31
+ for (const [name, server] of Object.entries(servers)) {
32
+ if (server.command) {
33
+ result[name] = {
34
+ command: server.command,
35
+ args: server.args,
36
+ env: server.env,
37
+ }
38
+ continue
39
+ }
40
+
41
+ if (!server.url) {
42
+ continue
43
+ }
44
+
45
+ if (hasExplicitSseTransport(server)) {
46
+ result[name] = {
47
+ url: server.url,
48
+ headers: server.headers,
49
+ }
50
+ continue
51
+ }
52
+
53
+ if (!hasExplicitRemoteTransport(server)) {
54
+ console.warn(
55
+ `Warning: Qwen MCP server "${name}" has an ambiguous remote transport; defaulting to Streamable HTTP.`,
56
+ )
57
+ }
58
+
59
+ result[name] = {
60
+ httpUrl: server.url,
61
+ headers: server.headers,
62
+ }
63
+ }
64
+
65
+ return result
66
+ }