From 305fea486f57661a1922a5764a3a1aa0f7cc9b8b Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 14:38:42 -0800 Subject: [PATCH] fix: Address review findings in Qwen converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix P1: Remove dead TOOL_MAP constant (defined but never referenced) - Fix P1: Replace curl fallback for remote MCP servers with warn-and-skip, matching the kiro pattern — curl is not an MCP server - Fix P1: Remove incorrect literal cwd field ("${extensionPath}${/}") from stdio MCP server config; the value was never interpolated - Fix P1: Fix plugin.name → plugin.manifest.name in generateContextFile (plugin.name does not exist on ClaudePlugin; produced "# undefined") - Fix P1: Wire qwenHome through resolveTargetOutputRoot; previously the --qwen-home CLI flag was parsed but silently discarded - Fix P1: Remove hardcoded "compound-engineering" from qwen output path; now uses plugin.manifest.name via new qwenHome + pluginName params - Fix P1: Collapse dead-code resolveQwenPaths branches (both returned identical structures; simplify to a single return) - Fix P3: Remove rewriting of .opencode/ paths to .qwen/ — Claude plugins do not reference opencode paths, and rewriting them is incorrect - Fix P3: inferTemperature now returns undefined for unrecognized agents instead of 0.3 (matching the explicit doc branch), letting the model use its default temperature - Fix P2: Add lookbehind guards to rewriteQwenPaths() matching kiro pattern to avoid rewriting paths inside compound tokens or URLs - Update --qwen-home default to ~/.qwen/extensions (plugin name appended) - Add qwen-converter.test.ts with 16 tests covering all scenarios Co-Authored-By: Claude --- src/commands/install.ts | 13 +- src/converters/claude-to-qwen.ts | 44 ++---- src/targets/qwen.ts | 17 --- tests/qwen-converter.test.ts | 238 +++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 57 deletions(-) create mode 100644 tests/qwen-converter.test.ts diff --git a/src/commands/install.ts b/src/commands/install.ts index edf9496..5f1ac09 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -45,7 +45,7 @@ export default defineCommand({ qwenHome: { type: "string", alias: "qwen-home", - description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions/compound-engineering)", + description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions)", }, also: { type: "string", @@ -89,7 +89,7 @@ export default defineCommand({ const outputRoot = resolveOutputRoot(args.output) const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) - const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions", "compound-engineering")) + const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions")) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -102,7 +102,7 @@ export default defineCommand({ throw new Error(`Target ${targetName} did not return a bundle.`) } const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput) + const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, qwenHome, plugin.manifest.name, hasExplicitOutput) await target.write(primaryOutputRoot, bundle) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) @@ -123,7 +123,7 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput) + const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, qwenHome, plugin.manifest.name, hasExplicitOutput) await handler.write(extraRoot, extraBundle) console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) } @@ -180,13 +180,14 @@ function resolveTargetOutputRoot( outputRoot: string, codexHome: string, piHome: string, + qwenHome: string, + pluginName: string, hasExplicitOutput: boolean, ): string { if (targetName === "codex") return codexHome if (targetName === "pi") return piHome if (targetName === "qwen") { - const base = hasExplicitOutput ? outputRoot : path.join(os.homedir(), ".qwen", "extensions") - return path.join(base, "compound-engineering") + return path.join(qwenHome, pluginName) } if (targetName === "droid") return path.join(os.homedir(), ".factory") if (targetName === "cursor") { diff --git a/src/converters/claude-to-qwen.ts b/src/converters/claude-to-qwen.ts index 99e4e64..c07b177 100644 --- a/src/converters/claude-to-qwen.ts +++ b/src/converters/claude-to-qwen.ts @@ -14,23 +14,6 @@ export type ClaudeToQwenOptions = { inferTemperature: boolean } -const TOOL_MAP: Record = { - bash: "bash", - read: "read", - write: "write", - edit: "edit", - grep: "grep", - glob: "glob", - list: "list", - webfetch: "webfetch", - skill: "skill", - patch: "patch", - task: "task", - question: "question", - todowrite: "todowrite", - todoread: "todoread", -} - export function convertClaudeToQwen(plugin: ClaudePlugin, options: ClaudeToQwenOptions): QwenBundle { const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options)) const cmdFiles = convertCommands(plugin.commands) @@ -119,20 +102,15 @@ function convertMcp(servers: Record): Record = { @@ -258,5 +234,5 @@ function inferTemperature(agent: ClaudeAgent): number | undefined { if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) { return 0.6 } - return 0.3 + return undefined } diff --git a/src/targets/qwen.ts b/src/targets/qwen.ts index 450524a..a822857 100644 --- a/src/targets/qwen.ts +++ b/src/targets/qwen.ts @@ -53,23 +53,6 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P } function resolveQwenPaths(outputRoot: string) { - const base = path.basename(outputRoot) - // Global install: ~/.qwen/extensions/ - // Project install: .qwen/extensions/ or at root - // If the output root already ends with "extensions" or contains ".qwen/extensions", write directly - if (base === "extensions" || outputRoot.includes(".qwen/extensions")) { - return { - root: outputRoot, - configPath: path.join(outputRoot, "qwen-extension.json"), - contextPath: path.join(outputRoot, "QWEN.md"), - agentsDir: path.join(outputRoot, "agents"), - commandsDir: path.join(outputRoot, "commands"), - skillsDir: path.join(outputRoot, "skills"), - } - } - - // Custom output directory - write directly to the output root (not nested) - // This is for project-level installs like ./my-extension return { root: outputRoot, configPath: path.join(outputRoot, "qwen-extension.json"), diff --git a/tests/qwen-converter.test.ts b/tests/qwen-converter.test.ts new file mode 100644 index 0000000..b9690a3 --- /dev/null +++ b/tests/qwen-converter.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToQwen } from "../src/converters/claude-to-qwen" +import { parseFrontmatter } from "../src/utils/frontmatter" +import type { ClaudePlugin } from "../src/types/claude" + +const fixturePlugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "compound-engineering", version: "1.2.0", description: "A plugin for engineers" }, + agents: [ + { + name: "security-sentinel", + description: "Security-focused agent", + capabilities: ["Threat modeling", "OWASP"], + model: "claude-sonnet-4-20250514", + body: "Focus on vulnerabilities in ~/.claude/settings.", + sourcePath: "/tmp/plugin/agents/security-sentinel.md", + }, + { + name: "brainstorm-agent", + description: "Creative brainstormer", + model: "inherit", + body: "Generate ideas.", + sourcePath: "/tmp/plugin/agents/brainstorm-agent.md", + }, + ], + commands: [ + { + name: "workflows:plan", + description: "Planning command", + argumentHint: "[FOCUS]", + model: "inherit", + allowedTools: ["Read"], + body: "Plan the work. Config at ~/.claude/settings.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + { + name: "disabled-cmd", + description: "Disabled", + model: "inherit", + allowedTools: [], + body: "Should be excluded.", + disableModelInvocation: true, + sourcePath: "/tmp/plugin/commands/disabled-cmd.md", + }, + ], + skills: [ + { + name: "existing-skill", + description: "Existing skill", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + hooks: undefined, + mcpServers: { + local: { command: "npx", args: ["-y", "some-mcp"], env: { API_KEY: "${YOUR_API_KEY}" } }, + remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } }, + }, +} + +const defaultOptions = { + agentMode: "subagent" as const, + inferTemperature: false, +} + +describe("convertClaudeToQwen", () => { + test("converts agents to yaml format with frontmatter", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + + const agent = bundle.agents.find((a) => a.name === "security-sentinel") + expect(agent).toBeDefined() + expect(agent!.format).toBe("yaml") + const parsed = parseFrontmatter(agent!.content) + expect(parsed.data.name).toBe("security-sentinel") + expect(parsed.data.description).toBe("Security-focused agent") + expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514") + expect(parsed.body).toContain("Focus on vulnerabilities") + }) + + test("agent with inherit model has no model field in frontmatter", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "brainstorm-agent") + expect(agent).toBeDefined() + const parsed = parseFrontmatter(agent!.content) + expect(parsed.data.model).toBeUndefined() + }) + + test("inferTemperature injects temperature based on agent name/description", () => { + const bundle = convertClaudeToQwen(fixturePlugin, { ...defaultOptions, inferTemperature: true }) + + const sentinel = bundle.agents.find((a) => a.name === "security-sentinel") + const parsed = parseFrontmatter(sentinel!.content) + expect(parsed.data.temperature).toBe(0.1) // review/security → 0.1 + + const brainstorm = bundle.agents.find((a) => a.name === "brainstorm-agent") + const bParsed = parseFrontmatter(brainstorm!.content) + expect(bParsed.data.temperature).toBe(0.6) // brainstorm → 0.6 + }) + + test("inferTemperature returns undefined for unrecognized agents (no temperature set)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [{ name: "my-helper", description: "Generic helper", model: "inherit", body: "help", sourcePath: "/tmp/a.md" }], + } + const bundle = convertClaudeToQwen(plugin, { ...defaultOptions, inferTemperature: true }) + const agent = bundle.agents[0] + const parsed = parseFrontmatter(agent.content) + expect(parsed.data.temperature).toBeUndefined() + }) + + test("converts commands to command files excluding disableModelInvocation", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + + const planCmd = bundle.commandFiles.find((c) => c.name === "workflows:plan") + expect(planCmd).toBeDefined() + const parsed = parseFrontmatter(planCmd!.content) + expect(parsed.data.description).toBe("Planning command") + expect(parsed.data.allowedTools).toEqual(["Read"]) + + const disabled = bundle.commandFiles.find((c) => c.name === "disabled-cmd") + expect(disabled).toBeUndefined() + }) + + test("config uses plugin manifest name and version", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + expect(bundle.config.name).toBe("compound-engineering") + expect(bundle.config.version).toBe("1.2.0") + expect(bundle.config.commands).toBe("commands") + expect(bundle.config.skills).toBe("skills") + expect(bundle.config.agents).toBe("agents") + }) + + test("stdio MCP servers are included in config", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + expect(bundle.config.mcpServers).toBeDefined() + const local = bundle.config.mcpServers!.local + expect(local.command).toBe("npx") + expect(local.args).toEqual(["-y", "some-mcp"]) + // No cwd field + expect((local as any).cwd).toBeUndefined() + }) + + test("remote MCP servers are skipped with a warning (not converted to curl)", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + // Only local (stdio) server should be present + expect(bundle.config.mcpServers).toBeDefined() + expect(bundle.config.mcpServers!.remote).toBeUndefined() + expect(bundle.config.mcpServers!.local).toBeDefined() + }) + + test("placeholder env vars are extracted as settings", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + expect(bundle.config.settings).toBeDefined() + const apiKeySetting = bundle.config.settings!.find((s) => s.envVar === "API_KEY") + expect(apiKeySetting).toBeDefined() + expect(apiKeySetting!.sensitive).toBe(true) + expect(apiKeySetting!.name).toBe("Api Key") + }) + + test("plugin with no MCP servers has no mcpServers in config", () => { + const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + expect(bundle.config.mcpServers).toBeUndefined() + }) + + test("context file uses plugin.manifest.name and manifest.description", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + expect(bundle.contextFile).toContain("# compound-engineering") + expect(bundle.contextFile).toContain("A plugin for engineers") + expect(bundle.contextFile).toContain("## Agents") + expect(bundle.contextFile).toContain("security-sentinel") + expect(bundle.contextFile).toContain("## Commands") + expect(bundle.contextFile).toContain("/workflows:plan") + // Disabled commands excluded + expect(bundle.contextFile).not.toContain("disabled-cmd") + expect(bundle.contextFile).toContain("## Skills") + expect(bundle.contextFile).toContain("existing-skill") + }) + + test("paths are rewritten from .claude/ to .qwen/ in agent and command content", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + + const agent = bundle.agents.find((a) => a.name === "security-sentinel") + expect(agent!.content).toContain("~/.qwen/settings") + expect(agent!.content).not.toContain("~/.claude/settings") + + const cmd = bundle.commandFiles.find((c) => c.name === "workflows:plan") + expect(cmd!.content).toContain("~/.qwen/settings") + expect(cmd!.content).not.toContain("~/.claude/settings") + }) + + test("opencode paths are NOT rewritten (only claude paths)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "test-agent", + description: "test", + model: "inherit", + body: "See .opencode/config and ~/.config/opencode/settings", + sourcePath: "/tmp/a.md", + }, + ], + } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + const agent = bundle.agents[0] + // opencode paths should NOT be rewritten + expect(agent.content).toContain(".opencode/config") + expect(agent.content).not.toContain(".qwen/config") + }) + + test("skillDirs passes through original skills", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + const skill = bundle.skillDirs.find((s) => s.name === "existing-skill") + expect(skill).toBeDefined() + expect(skill!.sourceDir).toBe("/tmp/plugin/skills/existing-skill") + }) + + test("normalizeModel prefixes claude models with anthropic/", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [{ name: "a", description: "d", model: "claude-opus-4-5", body: "b", sourcePath: "/tmp/a.md" }], + } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBe("anthropic/claude-opus-4-5") + }) + + test("normalizeModel passes through already-namespaced models unchanged", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [{ name: "a", description: "d", model: "google/gemini-2.0", body: "b", sourcePath: "/tmp/a.md" }], + } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBe("google/gemini-2.0") + }) +})