Skip to content

Commit d40b39f

Browse files
Merge pull request #220 from rlam3/main
feat: Add Qwen Code support
2 parents e2abb3d + 305fea4 commit d40b39f

7 files changed

Lines changed: 617 additions & 5 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin**
1818
/add-plugin compound-engineering
1919
```
2020

21-
## OpenCode, Codex, Droid, Pi, Gemini, Copilot & Kiro (experimental) Install
21+
## OpenCode, Codex, Droid, Pi, Gemini, Copilot, Kiro & Qwen (experimental) Install
2222

23-
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI, GitHub Copilot, and Kiro CLI.
23+
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI, GitHub Copilot, Kiro CLI, and Qwen Code.
2424

2525
```bash
2626
# convert the compound-engineering plugin into OpenCode format
@@ -43,6 +43,9 @@ bunx @every-env/compound-plugin install compound-engineering --to copilot
4343

4444
# convert to Kiro CLI format
4545
bunx @every-env/compound-plugin install compound-engineering --to kiro
46+
47+
# convert to Qwen Code format
48+
bunx @every-env/compound-plugin install compound-engineering --to qwen
4649
```
4750

4851
Local dev:
@@ -58,6 +61,7 @@ Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensio
5861
Gemini output is written to `.gemini/` with skills (from agents), commands (`.toml`), and `settings.json` (MCP servers). Namespaced commands create directory structure (`workflows:plan``commands/workflows/plan.toml`). Skills use the identical SKILL.md standard and pass through unchanged.
5962
Copilot output is written to `.github/` with agents (`.agent.md`), skills (`SKILL.md`), and `copilot-mcp-config.json`. Agents get Copilot frontmatter (`description`, `tools: ["*"]`, `infer: true`), commands are converted to agent skills, and MCP server env vars are prefixed with `COPILOT_MCP_`.
6063
Kiro output is written to `.kiro/` with custom agents (`.json` configs + prompt `.md` files), skills (from commands), pass-through skills, steering files (from CLAUDE.md), and `mcp.json`. Agents get `includeMcpJson: true` for MCP server access. Only stdio MCP servers are supported (HTTP servers are skipped with a warning).
64+
Qwen output is written to `~/.qwen/extensions/compound-engineering/` by default with `qwen-extension.json` (MCP servers), `QWEN.md` (context), agents (`.yaml`), commands (`.md`), and skills. Claude tool names are passed through unchanged. MCP server environment variables with placeholder values are extracted as settings in `qwen-extension.json`. Nested commands use colon separator (`workflows:plan``commands/workflows/plan.md`).
6165

6266
All provider targets are experimental and may change as the formats evolve.
6367

src/commands/install.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default defineCommand({
2525
to: {
2626
type: "string",
2727
default: "opencode",
28-
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro)",
28+
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | qwen)",
2929
},
3030
output: {
3131
type: "string",
@@ -42,6 +42,11 @@ export default defineCommand({
4242
alias: "pi-home",
4343
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
4444
},
45+
qwenHome: {
46+
type: "string",
47+
alias: "qwen-home",
48+
description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions)",
49+
},
4550
also: {
4651
type: "string",
4752
description: "Comma-separated extra targets to generate (ex: codex)",
@@ -84,6 +89,7 @@ export default defineCommand({
8489
const outputRoot = resolveOutputRoot(args.output)
8590
const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
8691
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
92+
const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions"))
8793

8894
const options = {
8995
agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
@@ -96,7 +102,7 @@ export default defineCommand({
96102
throw new Error(`Target ${targetName} did not return a bundle.`)
97103
}
98104
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
99-
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput)
105+
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, qwenHome, plugin.manifest.name, hasExplicitOutput)
100106
await target.write(primaryOutputRoot, bundle)
101107
console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`)
102108

@@ -117,7 +123,7 @@ export default defineCommand({
117123
console.warn(`Skipping ${extra}: no output returned.`)
118124
continue
119125
}
120-
const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput)
126+
const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, qwenHome, plugin.manifest.name, hasExplicitOutput)
121127
await handler.write(extraRoot, extraBundle)
122128
console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`)
123129
}
@@ -174,10 +180,15 @@ function resolveTargetOutputRoot(
174180
outputRoot: string,
175181
codexHome: string,
176182
piHome: string,
183+
qwenHome: string,
184+
pluginName: string,
177185
hasExplicitOutput: boolean,
178186
): string {
179187
if (targetName === "codex") return codexHome
180188
if (targetName === "pi") return piHome
189+
if (targetName === "qwen") {
190+
return path.join(qwenHome, pluginName)
191+
}
181192
if (targetName === "droid") return path.join(os.homedir(), ".factory")
182193
if (targetName === "cursor") {
183194
const base = hasExplicitOutput ? outputRoot : process.cwd()

src/converters/claude-to-qwen.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { formatFrontmatter } from "../utils/frontmatter"
2+
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
3+
import type {
4+
QwenAgentFile,
5+
QwenBundle,
6+
QwenCommandFile,
7+
QwenExtensionConfig,
8+
QwenMcpServer,
9+
QwenSetting,
10+
} from "../types/qwen"
11+
12+
export type ClaudeToQwenOptions = {
13+
agentMode: "primary" | "subagent"
14+
inferTemperature: boolean
15+
}
16+
17+
export function convertClaudeToQwen(plugin: ClaudePlugin, options: ClaudeToQwenOptions): QwenBundle {
18+
const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options))
19+
const cmdFiles = convertCommands(plugin.commands)
20+
const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
21+
const settings = extractSettings(plugin.mcpServers)
22+
23+
const config: QwenExtensionConfig = {
24+
name: plugin.manifest.name,
25+
version: plugin.manifest.version || "1.0.0",
26+
commands: "commands",
27+
skills: "skills",
28+
agents: "agents",
29+
}
30+
31+
if (mcp && Object.keys(mcp).length > 0) {
32+
config.mcpServers = mcp
33+
}
34+
35+
if (settings && settings.length > 0) {
36+
config.settings = settings
37+
}
38+
39+
const contextFile = generateContextFile(plugin)
40+
41+
return {
42+
config,
43+
agents: agentFiles,
44+
commandFiles: cmdFiles,
45+
skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
46+
contextFile,
47+
}
48+
}
49+
50+
function convertAgent(agent: ClaudeAgent, options: ClaudeToQwenOptions): QwenAgentFile {
51+
const frontmatter: Record<string, unknown> = {
52+
name: agent.name,
53+
description: agent.description,
54+
}
55+
56+
if (agent.model && agent.model !== "inherit") {
57+
frontmatter.model = normalizeModel(agent.model)
58+
}
59+
60+
if (options.inferTemperature) {
61+
const temperature = inferTemperature(agent)
62+
if (temperature !== undefined) {
63+
frontmatter.temperature = temperature
64+
}
65+
}
66+
67+
// Qwen supports both YAML and Markdown for agents
68+
// Using YAML format for structured config
69+
const content = formatFrontmatter(frontmatter, rewriteQwenPaths(agent.body))
70+
71+
return {
72+
name: agent.name,
73+
content,
74+
format: "yaml",
75+
}
76+
}
77+
78+
function convertCommands(commands: ClaudeCommand[]): QwenCommandFile[] {
79+
const files: QwenCommandFile[] = []
80+
for (const command of commands) {
81+
if (command.disableModelInvocation) continue
82+
const frontmatter: Record<string, unknown> = {
83+
description: command.description,
84+
}
85+
if (command.model && command.model !== "inherit") {
86+
frontmatter.model = normalizeModel(command.model)
87+
}
88+
if (command.allowedTools && command.allowedTools.length > 0) {
89+
frontmatter.allowedTools = command.allowedTools
90+
}
91+
const content = formatFrontmatter(frontmatter, rewriteQwenPaths(command.body))
92+
files.push({ name: command.name, content })
93+
}
94+
return files
95+
}
96+
97+
function convertMcp(servers: Record<string, ClaudeMcpServer>): Record<string, QwenMcpServer> {
98+
const result: Record<string, QwenMcpServer> = {}
99+
for (const [name, server] of Object.entries(servers)) {
100+
if (server.command) {
101+
result[name] = {
102+
command: server.command,
103+
args: server.args,
104+
env: server.env,
105+
}
106+
continue
107+
}
108+
109+
if (server.url) {
110+
// Qwen only supports stdio (command-based) MCP servers — skip remote servers
111+
console.warn(
112+
`Warning: Remote MCP server '${name}' (URL: ${server.url}) is not supported in Qwen format. Qwen only supports stdio MCP servers. Skipping.`,
113+
)
114+
}
115+
}
116+
return result
117+
}
118+
119+
function extractSettings(mcpServers?: Record<string, ClaudeMcpServer>): QwenSetting[] {
120+
const settings: QwenSetting[] = []
121+
if (!mcpServers) return settings
122+
123+
for (const [name, server] of Object.entries(mcpServers)) {
124+
if (server.env) {
125+
for (const [envVar, value] of Object.entries(server.env)) {
126+
// Only add settings for environment variables that look like placeholders
127+
if (value.startsWith("${") || value.includes("YOUR_") || value.includes("XXX")) {
128+
settings.push({
129+
name: formatSettingName(envVar),
130+
description: `Environment variable for ${name} MCP server`,
131+
envVar,
132+
sensitive: envVar.toLowerCase().includes("key") || envVar.toLowerCase().includes("token") || envVar.toLowerCase().includes("secret"),
133+
})
134+
}
135+
}
136+
}
137+
}
138+
139+
return settings
140+
}
141+
142+
function formatSettingName(envVar: string): string {
143+
return envVar
144+
.replace(/_/g, " ")
145+
.toLowerCase()
146+
.replace(/\b\w/g, (c) => c.toUpperCase())
147+
}
148+
149+
function generateContextFile(plugin: ClaudePlugin): string {
150+
const sections: string[] = []
151+
152+
// Plugin description
153+
sections.push(`# ${plugin.manifest.name}`)
154+
sections.push("")
155+
if (plugin.manifest.description) {
156+
sections.push(plugin.manifest.description)
157+
sections.push("")
158+
}
159+
160+
// Agents section
161+
if (plugin.agents.length > 0) {
162+
sections.push("## Agents")
163+
sections.push("")
164+
for (const agent of plugin.agents) {
165+
sections.push(`- **${agent.name}**: ${agent.description || "No description"}`)
166+
}
167+
sections.push("")
168+
}
169+
170+
// Commands section
171+
if (plugin.commands.length > 0) {
172+
sections.push("## Commands")
173+
sections.push("")
174+
for (const command of plugin.commands) {
175+
if (!command.disableModelInvocation) {
176+
sections.push(`- **/${command.name}**: ${command.description || "No description"}`)
177+
}
178+
}
179+
sections.push("")
180+
}
181+
182+
// Skills section
183+
if (plugin.skills.length > 0) {
184+
sections.push("## Skills")
185+
sections.push("")
186+
for (const skill of plugin.skills) {
187+
sections.push(`- ${skill.name}`)
188+
}
189+
sections.push("")
190+
}
191+
192+
return sections.join("\n")
193+
}
194+
195+
function rewriteQwenPaths(body: string): string {
196+
return body
197+
.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.qwen/")
198+
.replace(/(?<=^|\s|["'`])\.claude\//gm, ".qwen/")
199+
}
200+
201+
const CLAUDE_FAMILY_ALIASES: Record<string, string> = {
202+
haiku: "claude-haiku",
203+
sonnet: "claude-sonnet",
204+
opus: "claude-opus",
205+
}
206+
207+
function normalizeModel(model: string): string {
208+
if (model.includes("/")) return model
209+
if (CLAUDE_FAMILY_ALIASES[model]) {
210+
const resolved = `anthropic/${CLAUDE_FAMILY_ALIASES[model]}`
211+
console.warn(
212+
`Warning: bare model alias "${model}" mapped to "${resolved}".`,
213+
)
214+
return resolved
215+
}
216+
if (/^claude-/.test(model)) return `anthropic/${model}`
217+
if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}`
218+
if (/^gemini-/.test(model)) return `google/${model}`
219+
if (/^qwen-/.test(model)) return `qwen/${model}`
220+
return `anthropic/${model}`
221+
}
222+
223+
function inferTemperature(agent: ClaudeAgent): number | undefined {
224+
const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase()
225+
if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
226+
return 0.1
227+
}
228+
if (/(plan|planning|architecture|strategist|analysis|research)/.test(sample)) {
229+
return 0.2
230+
}
231+
if (/(doc|readme|changelog|editor|writer)/.test(sample)) {
232+
return 0.3
233+
}
234+
if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) {
235+
return 0.6
236+
}
237+
return undefined
238+
}

src/targets/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,23 @@ import type { PiBundle } from "../types/pi"
66
import type { CopilotBundle } from "../types/copilot"
77
import type { GeminiBundle } from "../types/gemini"
88
import type { KiroBundle } from "../types/kiro"
9+
import type { QwenBundle } from "../types/qwen"
910
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
1011
import { convertClaudeToCodex } from "../converters/claude-to-codex"
1112
import { convertClaudeToDroid } from "../converters/claude-to-droid"
1213
import { convertClaudeToPi } from "../converters/claude-to-pi"
1314
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
1415
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
1516
import { convertClaudeToKiro } from "../converters/claude-to-kiro"
17+
import { convertClaudeToQwen, type ClaudeToQwenOptions } from "../converters/claude-to-qwen"
1618
import { writeOpenCodeBundle } from "./opencode"
1719
import { writeCodexBundle } from "./codex"
1820
import { writeDroidBundle } from "./droid"
1921
import { writePiBundle } from "./pi"
2022
import { writeCopilotBundle } from "./copilot"
2123
import { writeGeminiBundle } from "./gemini"
2224
import { writeKiroBundle } from "./kiro"
25+
import { writeQwenBundle } from "./qwen"
2326

2427
export type TargetHandler<TBundle = unknown> = {
2528
name: string
@@ -71,4 +74,10 @@ export const targets: Record<string, TargetHandler> = {
7174
convert: convertClaudeToKiro as TargetHandler<KiroBundle>["convert"],
7275
write: writeKiroBundle as TargetHandler<KiroBundle>["write"],
7376
},
77+
qwen: {
78+
name: "qwen",
79+
implemented: true,
80+
convert: convertClaudeToQwen as TargetHandler<QwenBundle>["convert"],
81+
write: writeQwenBundle as TargetHandler<QwenBundle>["write"],
82+
},
7483
}

0 commit comments

Comments
 (0)