From 7a674c81b7173050de60eeecd41d139085a0fb70 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Apr 2026 21:55:44 -0400 Subject: [PATCH] feat(install): add Qwen Code runtime support (#2019) (#2077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Qwen Code as a supported installation target. Users can now run `npx get-shit-done-cc --qwen` to install all 68+ GSD commands as skills to `~/.qwen/skills/gsd-*/SKILL.md`, following the same open standard as Claude Code 2.1.88+. Changes: - `bin/install.js`: --qwen flag, getDirName/getGlobalDir/getConfigDirFromHome support, QWEN_CONFIG_DIR env var, install/uninstall pipelines, interactive picker option 12 (Trae→13, Windsurf→14, All→15), .qwen path replacements in copyCommandsAsClaudeSkills and copyWithPathReplacement, legacy commands/gsd cleanup, fix processAttribution hardcoded 'claude' → runtime-aware - `README.md`: Qwen Code in tagline, runtime list, verification commands, skills format NOTE, install/uninstall examples, flag reference, env vars - `tests/qwen-install.test.cjs`: 13 tests covering directory mapping, env var precedence, install/uninstall lifecycle, artifact preservation - `tests/qwen-skills-migration.test.cjs`: 11 tests covering frontmatter conversion, path replacement, stale skill cleanup, SKILL.md format validation - `tests/multi-runtime-select.test.cjs`: Updated for new option numbering Closes #2019 Co-authored-by: Muhammad Co-authored-by: Jonathan Lima Co-authored-by: Claude Sonnet 4.6 --- README.md | 16 +- bin/install.js | 97 +++++++-- tests/multi-runtime-select.test.cjs | 47 +++-- tests/qwen-install.test.cjs | 178 +++++++++++++++++ tests/qwen-skills-migration.test.cjs | 286 +++++++++++++++++++++++++++ 5 files changed, 589 insertions(+), 35 deletions(-) create mode 100644 tests/qwen-install.test.cjs create mode 100644 tests/qwen-skills-migration.test.cjs diff --git a/README.md b/README.md index 825dc53ac..07788c445 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) -**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, CodeBuddy, and Cline.** +**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, Cline, and CodeBuddy.** **Solves context rot — the quality degradation that happens as Claude fills its context window.** @@ -106,17 +106,17 @@ npx get-shit-done-cc@latest ``` The installer prompts you to choose: -1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, CodeBuddy, Cline, or all (interactive multi-select — pick multiple runtimes in a single install session) +1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, CodeBuddy, Cline, or all (interactive multi-select — pick multiple runtimes in a single install session) 2. **Location** — Global (all projects) or local (current project only) Verify with: -- Claude Code / Gemini / Copilot / Antigravity: `/gsd-help` +- Claude Code / Gemini / Copilot / Antigravity / Qwen Code: `/gsd-help` - OpenCode / Kilo / Augment / Trae / CodeBuddy: `/gsd-help` - Codex: `$gsd-help` - Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists > [!NOTE] -> Claude Code 2.1.88+ and Codex install as skills (`skills/gsd-*/SKILL.md`). Older Claude Code versions use `commands/gsd/`. Cline uses `.clinerules` for configuration. The installer handles all formats automatically. +> Claude Code 2.1.88+, Qwen Code, and Codex install as skills (`skills/gsd-*/SKILL.md`). Older Claude Code versions use `commands/gsd/`. Cline uses `.clinerules` for configuration. The installer handles all formats automatically. > [!TIP] > For source-based installs or environments where npm is unavailable, see **[docs/manual-update.md](docs/manual-update.md)**. @@ -175,6 +175,10 @@ npx get-shit-done-cc --augment --local # Install to ./.augment/ npx get-shit-done-cc --trae --global # Install to ~/.trae/ npx get-shit-done-cc --trae --local # Install to ./.trae/ +# Qwen Code +npx get-shit-done-cc --qwen --global # Install to ~/.qwen/ +npx get-shit-done-cc --qwen --local # Install to ./.qwen/ + # CodeBuddy npx get-shit-done-cc --codebuddy --global # Install to ~/.codebuddy/ npx get-shit-done-cc --codebuddy --local # Install to ./.codebuddy/ @@ -188,7 +192,7 @@ npx get-shit-done-cc --all --global # Install to all directories ``` Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt. -Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, `--trae`, `--codebuddy`, `--cline`, or `--all` to skip the runtime prompt. +Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, `--trae`, `--qwen`, `--codebuddy`, `--cline`, or `--all` to skip the runtime prompt. Use `--sdk` to also install the GSD SDK CLI (`gsd-sdk`) for headless autonomous execution. @@ -850,6 +854,7 @@ npx get-shit-done-cc --windsurf --global --uninstall npx get-shit-done-cc --antigravity --global --uninstall npx get-shit-done-cc --augment --global --uninstall npx get-shit-done-cc --trae --global --uninstall +npx get-shit-done-cc --qwen --global --uninstall npx get-shit-done-cc --codebuddy --global --uninstall npx get-shit-done-cc --cline --global --uninstall @@ -865,6 +870,7 @@ npx get-shit-done-cc --windsurf --local --uninstall npx get-shit-done-cc --antigravity --local --uninstall npx get-shit-done-cc --augment --local --uninstall npx get-shit-done-cc --trae --local --uninstall +npx get-shit-done-cc --qwen --local --uninstall npx get-shit-done-cc --codebuddy --local --uninstall npx get-shit-done-cc --cline --local --uninstall ``` diff --git a/bin/install.js b/bin/install.js index c08cdae32..50cd93e50 100755 --- a/bin/install.js +++ b/bin/install.js @@ -70,6 +70,7 @@ const hasCursor = args.includes('--cursor'); const hasWindsurf = args.includes('--windsurf'); const hasAugment = args.includes('--augment'); const hasTrae = args.includes('--trae'); +const hasQwen = args.includes('--qwen'); const hasCodebuddy = args.includes('--codebuddy'); const hasCline = args.includes('--cline'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working @@ -79,7 +80,7 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u'); // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; if (hasAll) { - selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'cline']; + selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'codebuddy', 'cline']; } else if (hasBoth) { selectedRuntimes = ['claude', 'opencode']; } else { @@ -94,6 +95,7 @@ if (hasAll) { if (hasWindsurf) selectedRuntimes.push('windsurf'); if (hasAugment) selectedRuntimes.push('augment'); if (hasTrae) selectedRuntimes.push('trae'); + if (hasQwen) selectedRuntimes.push('qwen'); if (hasCodebuddy) selectedRuntimes.push('codebuddy'); if (hasCline) selectedRuntimes.push('cline'); } @@ -144,6 +146,7 @@ function getDirName(runtime) { if (runtime === 'windsurf') return '.windsurf'; if (runtime === 'augment') return '.augment'; if (runtime === 'trae') return '.trae'; + if (runtime === 'qwen') return '.qwen'; if (runtime === 'codebuddy') return '.codebuddy'; if (runtime === 'cline') return '.cline'; return '.claude'; @@ -178,6 +181,7 @@ function getConfigDirFromHome(runtime, isGlobal) { if (runtime === 'windsurf') return "'.windsurf'"; if (runtime === 'augment') return "'.augment'"; if (runtime === 'trae') return "'.trae'"; + if (runtime === 'qwen') return "'.qwen'"; if (runtime === 'codebuddy') return "'.codebuddy'"; if (runtime === 'cline') return "'.cline'"; return "'.claude'"; @@ -342,6 +346,16 @@ function getGlobalDir(runtime, explicitDir = null) { return path.join(os.homedir(), '.trae'); } + if (runtime === 'qwen') { + if (explicitDir) { + return expandTilde(explicitDir); + } + if (process.env.QWEN_CONFIG_DIR) { + return expandTilde(process.env.QWEN_CONFIG_DIR); + } + return path.join(os.homedir(), '.qwen'); + } + if (runtime === 'codebuddy') { // CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy if (explicitDir) { @@ -384,7 +398,7 @@ const banner = '\n' + '\n' + ' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' + ' A meta-prompting, context engineering and spec-driven\n' + - ' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Cline and CodeBuddy by TÂCHES.\n'; + ' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Cline and CodeBuddy by TÂCHES.\n'; // Parse --config-dir argument function parseConfigDirArg() { @@ -422,7 +436,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -3939,7 +3953,10 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti content = content.replace(/~\/\.claude\//g, pathPrefix); content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`); - content = processAttribution(content, getCommitAttribution('claude')); + content = content.replace(/~\/\.qwen\//g, pathPrefix); + content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); + content = content.replace(/\.\/\.qwen\//g, `./${getDirName(runtime)}/`); + content = processAttribution(content, getCommitAttribution(runtime)); content = convertClaudeCommandToClaudeSkill(content, skillName); fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); @@ -4057,6 +4074,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand const isWindsurf = runtime === 'windsurf'; const isAugment = runtime === 'augment'; const isTrae = runtime === 'trae'; + const isQwen = runtime === 'qwen'; const isCline = runtime === 'cline'; const dirName = getDirName(runtime); @@ -4085,6 +4103,9 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = content.replace(globalClaudeRegex, pathPrefix); content = content.replace(globalClaudeHomeRegex, pathPrefix); content = content.replace(localClaudeRegex, `./${dirName}/`); + content = content.replace(/~\/\.qwen\//g, pathPrefix); + content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); + content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); } content = processAttribution(content, getCommitAttribution(runtime)); @@ -4349,6 +4370,7 @@ function uninstall(isGlobal, runtime = 'claude') { const isWindsurf = runtime === 'windsurf'; const isAugment = runtime === 'augment'; const isTrae = runtime === 'trae'; + const isQwen = runtime === 'qwen'; const isCodebuddy = runtime === 'codebuddy'; const dirName = getDirName(runtime); @@ -4372,6 +4394,7 @@ function uninstall(isGlobal, runtime = 'claude') { if (runtime === 'windsurf') runtimeLabel = 'Windsurf'; if (runtime === 'augment') runtimeLabel = 'Augment'; if (runtime === 'trae') runtimeLabel = 'Trae'; + if (runtime === 'qwen') runtimeLabel = 'Qwen Code'; if (runtime === 'codebuddy') runtimeLabel = 'CodeBuddy'; console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); @@ -4502,6 +4525,31 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Removed ${skillCount} Antigravity skills`); } } + } else if (isQwen) { + const skillsDir = path.join(targetDir, 'skills'); + if (fs.existsSync(skillsDir)) { + let skillCount = 0; + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); + skillCount++; + } + } + if (skillCount > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${skillCount} Qwen Code skills`); + } + } + + const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); + if (fs.existsSync(legacyCommandsDir)) { + const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); + fs.rmSync(legacyCommandsDir, { recursive: true }); + removedCount++; + console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); + restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); + } } else if (isGemini) { // Gemini: still uses commands/gsd/ const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); @@ -5298,6 +5346,7 @@ function install(isGlobal, runtime = 'claude') { const isWindsurf = runtime === 'windsurf'; const isAugment = runtime === 'augment'; const isTrae = runtime === 'trae'; + const isQwen = runtime === 'qwen'; const isCodebuddy = runtime === 'codebuddy'; const isCline = runtime === 'cline'; const dirName = getDirName(runtime); @@ -5338,6 +5387,7 @@ function install(isGlobal, runtime = 'claude') { if (isWindsurf) runtimeLabel = 'Windsurf'; if (isAugment) runtimeLabel = 'Augment'; if (isTrae) runtimeLabel = 'Trae'; + if (isQwen) runtimeLabel = 'Qwen Code'; if (isCodebuddy) runtimeLabel = 'CodeBuddy'; if (isCline) runtimeLabel = 'Cline'; @@ -5447,6 +5497,29 @@ function install(isGlobal, runtime = 'claude') { } else { failures.push('skills/gsd-*'); } + } else if (isQwen) { + const skillsDir = path.join(targetDir, 'skills'); + const gsdSrc = path.join(src, 'commands', 'gsd'); + copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); + if (fs.existsSync(skillsDir)) { + const count = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + if (count > 0) { + console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } + } else { + failures.push('skills/gsd-*'); + } + + const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); + if (fs.existsSync(legacyCommandsDir)) { + const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); + fs.rmSync(legacyCommandsDir, { recursive: true }); + console.log(` ${green}✓${reset} Removed legacy commands/gsd/ directory`); + restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); + } } else if (isCodebuddy) { const skillsDir = path.join(targetDir, 'skills'); const gsdSrc = path.join(src, 'commands', 'gsd'); @@ -6289,10 +6362,11 @@ function promptRuntime(callback) { '9': 'gemini', '10': 'kilo', '11': 'opencode', - '12': 'trae', - '13': 'windsurf' + '12': 'qwen', + '13': 'trae', + '14': 'windsurf' }; - const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'trae', 'windsurf']; + const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} ${cyan}2${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset} @@ -6305,9 +6379,10 @@ function promptRuntime(callback) { ${cyan}9${reset}) Gemini ${dim}(~/.gemini)${reset} ${cyan}10${reset}) Kilo ${dim}(~/.config/kilo)${reset} ${cyan}11${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - ${cyan}12${reset}) Trae ${dim}(~/.trae)${reset} - ${cyan}13${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset} - ${cyan}14${reset}) All + ${cyan}12${reset}) Qwen Code ${dim}(~/.qwen)${reset} + ${cyan}13${reset}) Trae ${dim}(~/.trae)${reset} + ${cyan}14${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset} + ${cyan}15${reset}) All ${dim}Select multiple: 1,2,6 or 1 2 6${reset} `); @@ -6318,7 +6393,7 @@ function promptRuntime(callback) { const input = answer.trim() || '1'; // "All" shortcut - if (input === '14') { + if (input === '15') { callback(allRuntimes); return; } diff --git a/tests/multi-runtime-select.test.cjs b/tests/multi-runtime-select.test.cjs index f91c9cce2..c0b1b0004 100644 --- a/tests/multi-runtime-select.test.cjs +++ b/tests/multi-runtime-select.test.cjs @@ -29,10 +29,11 @@ const runtimeMap = { '9': 'gemini', '10': 'kilo', '11': 'opencode', - '12': 'trae', - '13': 'windsurf' + '12': 'qwen', + '13': 'trae', + '14': 'windsurf' }; -const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'trae', 'windsurf']; +const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; /** * Simulate the parsing logic from promptRuntime without requiring readline. @@ -41,7 +42,7 @@ const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', ' function parseRuntimeInput(input) { input = input.trim() || '1'; - if (input === '14') { + if (input === '15') { return allRuntimes; } @@ -89,16 +90,20 @@ describe('multi-runtime selection parsing', () => { assert.deepStrictEqual(parseRuntimeInput('11'), ['opencode']); }); + test('single choice for qwen', () => { + assert.deepStrictEqual(parseRuntimeInput('12'), ['qwen']); + }); + test('single choice for trae', () => { - assert.deepStrictEqual(parseRuntimeInput('12'), ['trae']); + assert.deepStrictEqual(parseRuntimeInput('13'), ['trae']); }); test('single choice for windsurf', () => { - assert.deepStrictEqual(parseRuntimeInput('13'), ['windsurf']); + assert.deepStrictEqual(parseRuntimeInput('14'), ['windsurf']); }); - test('choice 14 returns all runtimes', () => { - assert.deepStrictEqual(parseRuntimeInput('14'), allRuntimes); + test('choice 15 returns all runtimes', () => { + assert.deepStrictEqual(parseRuntimeInput('15'), allRuntimes); }); test('empty input defaults to claude', () => { @@ -107,13 +112,13 @@ describe('multi-runtime selection parsing', () => { }); test('invalid choices are ignored, falls back to claude if all invalid', () => { - assert.deepStrictEqual(parseRuntimeInput('15'), ['claude']); + assert.deepStrictEqual(parseRuntimeInput('16'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']); }); test('invalid choices mixed with valid are filtered out', () => { - assert.deepStrictEqual(parseRuntimeInput('1,15,7'), ['claude', 'copilot']); + assert.deepStrictEqual(parseRuntimeInput('1,16,7'), ['claude', 'copilot']); assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['augment']); }); @@ -129,7 +134,7 @@ describe('multi-runtime selection parsing', () => { }); describe('install.js source contains multi-select support', () => { - test('runtimeMap is defined with all 13 runtimes', () => { + test('runtimeMap is defined with all 14 runtimes', () => { for (const [key, name] of Object.entries(runtimeMap)) { assert.ok( installSrc.includes(`'${key}': '${name}'`), @@ -146,21 +151,25 @@ describe('install.js source contains multi-select support', () => { } }); - test('all shortcut uses option 14', () => { + test('all shortcut uses option 15', () => { assert.ok( - installSrc.includes("if (input === '14')"), - 'all shortcut uses option 14' + installSrc.includes("if (input === '15')"), + 'all shortcut uses option 15' ); }); - test('prompt lists Trae as option 12 and All as option 14', () => { + test('prompt lists Qwen Code as option 12, Trae as option 13 and All as option 15', () => { assert.ok( - installSrc.includes('12${reset}) Trae'), - 'prompt lists Trae as option 12' + installSrc.includes('12${reset}) Qwen Code'), + 'prompt lists Qwen Code as option 12' ); assert.ok( - installSrc.includes('14${reset}) All'), - 'prompt lists All as option 14' + installSrc.includes('13${reset}) Trae'), + 'prompt lists Trae as option 13' + ); + assert.ok( + installSrc.includes('15${reset}) All'), + 'prompt lists All as option 15' ); }); diff --git a/tests/qwen-install.test.cjs b/tests/qwen-install.test.cjs new file mode 100644 index 000000000..0af4f4da0 --- /dev/null +++ b/tests/qwen-install.test.cjs @@ -0,0 +1,178 @@ +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + getDirName, + getGlobalDir, + getConfigDirFromHome, + install, + uninstall, + writeManifest, +} = require('../bin/install.js'); + +describe('Qwen Code runtime directory mapping', () => { + test('maps Qwen to .qwen for local installs', () => { + assert.strictEqual(getDirName('qwen'), '.qwen'); + }); + + test('maps Qwen to ~/.qwen for global installs', () => { + assert.strictEqual(getGlobalDir('qwen'), path.join(os.homedir(), '.qwen')); + }); + + test('returns .qwen config fragments for local and global installs', () => { + assert.strictEqual(getConfigDirFromHome('qwen', false), "'.qwen'"); + assert.strictEqual(getConfigDirFromHome('qwen', true), "'.qwen'"); + }); +}); + +describe('getGlobalDir (Qwen Code)', () => { + let originalQwenConfigDir; + + beforeEach(() => { + originalQwenConfigDir = process.env.QWEN_CONFIG_DIR; + }); + + afterEach(() => { + if (originalQwenConfigDir !== undefined) { + process.env.QWEN_CONFIG_DIR = originalQwenConfigDir; + } else { + delete process.env.QWEN_CONFIG_DIR; + } + }); + + test('returns ~/.qwen with no env var or explicit dir', () => { + delete process.env.QWEN_CONFIG_DIR; + const result = getGlobalDir('qwen'); + assert.strictEqual(result, path.join(os.homedir(), '.qwen')); + }); + + test('returns explicit dir when provided', () => { + const result = getGlobalDir('qwen', '/custom/qwen-path'); + assert.strictEqual(result, '/custom/qwen-path'); + }); + + test('respects QWEN_CONFIG_DIR env var', () => { + process.env.QWEN_CONFIG_DIR = '~/custom-qwen'; + const result = getGlobalDir('qwen'); + assert.strictEqual(result, path.join(os.homedir(), 'custom-qwen')); + }); + + test('explicit dir takes priority over QWEN_CONFIG_DIR', () => { + process.env.QWEN_CONFIG_DIR = '~/from-env'; + const result = getGlobalDir('qwen', '/explicit/path'); + assert.strictEqual(result, '/explicit/path'); + }); + + test('does not break other runtimes', () => { + assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude')); + assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex')); + }); +}); + +describe('Qwen Code local install/uninstall', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-qwen-install-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('installs GSD into ./.qwen and removes it cleanly', () => { + const result = install(false, 'qwen'); + const targetDir = path.join(tmpDir, '.qwen'); + + assert.strictEqual(result.runtime, 'qwen'); + assert.strictEqual(result.configDir, fs.realpathSync(targetDir)); + + assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION'))); + assert.ok(fs.existsSync(path.join(targetDir, 'agents'))); + + const manifest = writeManifest(targetDir, 'qwen'); + assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd-help/')), manifest); + + uninstall(false, 'qwen'); + + assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')), 'Qwen skill directory removed'); + assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed'); + }); +}); + +describe('E2E: Qwen Code uninstall skills cleanup', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-qwen-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('removes all gsd-* skill directories on --qwen --uninstall', () => { + const targetDir = path.join(tmpDir, '.qwen'); + install(false, 'qwen'); + + const skillsDir = path.join(targetDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills dir exists after install'); + + const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.ok(installedSkills.length > 0, `found ${installedSkills.length} gsd-* skill dirs before uninstall`); + + uninstall(false, 'qwen'); + + if (fs.existsSync(skillsDir)) { + const remainingGsd = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.strictEqual(remainingGsd.length, 0, + `Expected 0 gsd-* skill dirs after uninstall, found: ${remainingGsd.map(e => e.name).join(', ')}`); + } + }); + + test('preserves non-GSD skill directories during --qwen --uninstall', () => { + const targetDir = path.join(tmpDir, '.qwen'); + install(false, 'qwen'); + + const customSkillDir = path.join(targetDir, 'skills', 'my-custom-skill'); + fs.mkdirSync(customSkillDir, { recursive: true }); + fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n'); + + assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), 'custom skill exists before uninstall'); + + uninstall(false, 'qwen'); + + assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), + 'Non-GSD skill directory should be preserved after Qwen uninstall'); + }); + + test('removes engine directory on --qwen --uninstall', () => { + const targetDir = path.join(tmpDir, '.qwen'); + install(false, 'qwen'); + + assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')), + 'engine exists before uninstall'); + + uninstall(false, 'qwen'); + + assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), + 'get-shit-done engine should be removed after Qwen uninstall'); + }); +}); diff --git a/tests/qwen-skills-migration.test.cjs b/tests/qwen-skills-migration.test.cjs new file mode 100644 index 000000000..10cce4b15 --- /dev/null +++ b/tests/qwen-skills-migration.test.cjs @@ -0,0 +1,286 @@ +/** + * GSD Tools Tests - Qwen Code Skills Migration + * + * Tests for installing GSD for Qwen Code using the standard + * skills/gsd-xxx/SKILL.md format (same open standard as Claude Code 2.1.88+). + * + * Uses node:test and node:assert (NOT Jest). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const { + convertClaudeCommandToClaudeSkill, + copyCommandsAsClaudeSkills, +} = require('../bin/install.js'); + +// ─── convertClaudeCommandToClaudeSkill (used by Qwen via copyCommandsAsClaudeSkills) ── + +describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => { + test('preserves allowed-tools multiline YAML list', () => { + const input = [ + '---', + 'name: gsd:next', + 'description: Advance to the next step', + 'allowed-tools:', + ' - Read', + ' - Bash', + ' - Grep', + '---', + '', + 'Body content here.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next'); + assert.ok(result.includes('allowed-tools:'), 'allowed-tools field is present'); + assert.ok(result.includes('Read'), 'Read tool preserved'); + assert.ok(result.includes('Bash'), 'Bash tool preserved'); + assert.ok(result.includes('Grep'), 'Grep tool preserved'); + }); + + test('preserves argument-hint', () => { + const input = [ + '---', + 'name: gsd:debug', + 'description: Debug issues', + 'argument-hint: "[issue description]"', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Debug body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-debug'); + assert.ok(result.includes('argument-hint:'), 'argument-hint field is present'); + assert.ok( + result.includes('[issue description]'), + 'argument-hint value preserved' + ); + }); + + test('converts name format from gsd:xxx to skill naming', () => { + const input = [ + '---', + 'name: gsd:next', + 'description: Advance workflow', + '---', + '', + 'Body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next'); + assert.ok(result.includes('name: gsd-next'), 'name uses skill naming convention'); + assert.ok(!result.includes('name: gsd:next'), 'old name format removed'); + }); + + test('preserves body content unchanged', () => { + const body = '\n\nDo the thing.\n\n\n\nStep 1.\nStep 2.\n\n'; + const input = [ + '---', + 'name: gsd:test', + 'description: Test command', + '---', + body, + ].join(''); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test'); + assert.ok(result.includes(''), 'objective tag preserved'); + assert.ok(result.includes('Do the thing.'), 'body text preserved'); + assert.ok(result.includes(''), 'process tag preserved'); + }); + + test('produces valid SKILL.md frontmatter starting with ---', () => { + const input = [ + '---', + 'name: gsd:plan', + 'description: Plan a phase', + '---', + '', + 'Plan body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plan'); + assert.ok(result.startsWith('---\n'), 'frontmatter starts with ---'); + assert.ok(result.includes('\n---\n'), 'frontmatter closes with ---'); + }); +}); + +// ─── copyCommandsAsClaudeSkills (used for Qwen skills install) ───────────── + +describe('Qwen Code: copyCommandsAsClaudeSkills', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-qwen-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + test('creates skills/gsd-xxx/SKILL.md directory structure', () => { + // Create source command files + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'quick.md'), [ + '---', + 'name: gsd:quick', + 'description: Execute a quick task', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Quick task body', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/prefix/', 'qwen', false); + + // Verify SKILL.md was created + const skillPath = path.join(skillsDir, 'gsd-quick', 'SKILL.md'); + assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists'); + + // Verify content + const content = fs.readFileSync(skillPath, 'utf8'); + assert.ok(content.includes('name: gsd-quick'), 'skill name converted'); + assert.ok(content.includes('description:'), 'description present'); + assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved'); + assert.ok(content.includes(''), 'body content preserved'); + }); + + test('replaces ~/.claude/ paths with pathPrefix', () => { + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'next.md'), [ + '---', + 'name: gsd:next', + 'description: Next step', + '---', + '', + 'Reference: @~/.claude/get-shit-done/workflows/next.md', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.qwen/', 'qwen', false); + + const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8'); + assert.ok(content.includes('$HOME/.qwen/'), 'path replaced to .qwen/'); + assert.ok(!content.includes('~/.claude/'), 'old claude path removed'); + }); + + test('replaces $HOME/.claude/ paths with pathPrefix', () => { + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'plan.md'), [ + '---', + 'name: gsd:plan', + 'description: Plan phase', + '---', + '', + 'Reference: $HOME/.claude/get-shit-done/workflows/plan.md', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.qwen/', 'qwen', false); + + const content = fs.readFileSync(path.join(skillsDir, 'gsd-plan', 'SKILL.md'), 'utf8'); + assert.ok(content.includes('$HOME/.qwen/'), 'path replaced to .qwen/'); + assert.ok(!content.includes('$HOME/.claude/'), 'old claude path removed'); + }); + + test('removes stale gsd- skills before installing new ones', () => { + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'quick.md'), [ + '---', + 'name: gsd:quick', + 'description: Quick task', + '---', + '', + 'Body', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + // Pre-create a stale skill + fs.mkdirSync(path.join(skillsDir, 'gsd-old-skill'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'gsd-old-skill', 'SKILL.md'), 'old'); + + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'qwen', false); + + assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd-old-skill')), 'stale skill removed'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-quick', 'SKILL.md')), 'new skill installed'); + }); + + test('preserves agent field in frontmatter', () => { + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'execute.md'), [ + '---', + 'name: gsd:execute', + 'description: Execute phase', + 'agent: gsd-executor', + 'allowed-tools:', + ' - Read', + ' - Bash', + ' - Task', + '---', + '', + 'Execute body', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'qwen', false); + + const content = fs.readFileSync(path.join(skillsDir, 'gsd-execute', 'SKILL.md'), 'utf8'); + assert.ok(content.includes('agent: gsd-executor'), 'agent field preserved'); + }); +}); + +// ─── Integration: SKILL.md format validation ──────────────────────────────── + +describe('Qwen Code: SKILL.md format validation', () => { + test('SKILL.md frontmatter is valid YAML structure', () => { + const input = [ + '---', + 'name: gsd:review', + 'description: Code review with quality checks', + 'argument-hint: "[PR number or branch]"', + 'agent: gsd-code-reviewer', + 'allowed-tools:', + ' - Read', + ' - Grep', + ' - Bash', + '---', + '', + 'Review code', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review'); + + // Parse the frontmatter + const fmMatch = result.match(/^---\n([\s\S]*?)\n---/); + assert.ok(fmMatch, 'has frontmatter block'); + + const fmLines = fmMatch[1].split('\n'); + const hasName = fmLines.some(l => l.startsWith('name: gsd-review')); + const hasDesc = fmLines.some(l => l.startsWith('description:')); + const hasAgent = fmLines.some(l => l.startsWith('agent:')); + const hasTools = fmLines.some(l => l.startsWith('allowed-tools:')); + + assert.ok(hasName, 'name field correct'); + assert.ok(hasDesc, 'description field present'); + assert.ok(hasAgent, 'agent field present'); + assert.ok(hasTools, 'allowed-tools field present'); + }); +});