From 73b9d1dac094f2541723cf1559c481ddc8189444 Mon Sep 17 00:00:00 2001 From: Oleksander Palian Date: Thu, 30 Apr 2026 05:37:57 +0300 Subject: [PATCH] fix(install): use colon namespace for Gemini slash commands (#2768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(install): use colon namespace for Gemini slash commands and help reference This fixes unexecutable command recommendations in Gemini CLI by correctly namespacing slash commands (/gsd: instead of /gsd-) in all installed artifacts (agents, commands, workflows). - Implements a lazy command roster discovery to ensure 100% accurate conversion and protect file paths, URLs, and agent names. - Adds isolated behavioral and unit tests covering all boundary cases. - Fixes hardcoded command strings in banners and help output. Closes #2783 * fix(install): close roster gaps in Gemini /gsd- → /gsd: conversion (#2783) Addresses adversarial review findings on PR #2768: - Restore regex boundaries (lookbehind + extension lookahead). Roster-only matching was insufficient: a URL like `https://example.com/gsd-plan-phase` ends in a known command and would be incorrectly converted. Boundaries + roster now agree before any conversion fires. - Smarter trailing lookahead `(?!\.[a-z])` distinguishes file extensions (`.cjs`, `.md`) from sentence-ending punctuation (`.` at end of input or before whitespace), so `/gsd-help.` correctly converts. - Fail loud on missing roster. `commands/gsd/` not found previously fell through to an empty Set, silently no-op'ing every conversion — exactly the bug this code exists to prevent. Now emits a one-shot console.warn (gated on GSD_TEST_MODE) before returning the empty set. - Drop unnecessary `i` flag — GSD commands are always lowercase; matching uppercase tokens against a lowercase roster always misses anyway. - Export `_resetGsdCommandRoster` for test isolation against the module-level cache. Test additions pin the actual safety property of the roster check by using KNOWN command names embedded in URLs and sub-paths — the cases the prior tests didn't reach because they used `gsd-tools` (not in roster). Added a roster-load assertion that fails loudly if the empty-Set fallback path silently neutralises conversions. Co-Authored-By: Claude Opus 4.7 * fix(install): centralize stripping and add structural test assertions CodeRabbit findings on the prior commit: - (actionable) Centralizing the Gemini conversion through convertClaudeToGeminiMarkdown dropped the stripSubTags() call that the inline command path used to make before TOML conversion. Move stripSubTags inside convertClaudeToGeminiMarkdown so command/agent/non-command Gemini outputs all have consistently stripped. Remove the now-redundant stripSubTags call in convertClaudeToGeminiAgent (single source of truth). - (nitpick) Replace `.includes()` checks in the TOML test with structured parsing — JSON-decode each TOML value and assert on parsed fields, per the project's "tests parse, never grep" convention. - (nitpick) Strengthen the install behavioral test to read a real installed artifact (.gemini/commands/gsd/plan-phase.toml), parse it, and assert the prompt body actually contains a /gsd: reference and no unconverted /gsd-plan-phase. A directory-only check would have passed even if every conversion silently no-op'd. - Add a regression test that tags are stripped through the convertClaudeToGeminiMarkdown pipeline. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Tom Boucher Co-authored-by: Claude Opus 4.7 --- bin/install.js | 111 ++++++++++++++++--- tests/gemini-namespacing.test.cjs | 177 ++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 tests/gemini-namespacing.test.cjs diff --git a/bin/install.js b/bin/install.js index 4d5de9537..6e9035270 100755 --- a/bin/install.js +++ b/bin/install.js @@ -4317,6 +4317,84 @@ function stripSubTags(content) { * - skills: must be removed (causes validation error) * - mcp__* tools: must be excluded (auto-discovered at runtime) */ +let _gsdCommandRoster = null; +let _gsdCommandRosterWarned = false; + +/** + * Get the list of known GSD commands from the source directory. + * Caches the result after the first scan. Emits a one-shot warning if the + * source directory cannot be located — an empty roster silently neutralises + * every Gemini slash-command conversion, which is the bug this code exists + * to prevent. The warning is gated on GSD_TEST_MODE to keep test output clean. + * @returns {Set} Set of command names (without .md extension) + */ +function getGsdCommandRoster() { + if (_gsdCommandRoster) return _gsdCommandRoster; + const baseDir = (typeof __dirname !== 'undefined') ? __dirname : process.cwd(); + const gsdSrc = path.join(baseDir, '..', 'commands', 'gsd'); + if (fs.existsSync(gsdSrc)) { + _gsdCommandRoster = new Set( + fs.readdirSync(gsdSrc) + .filter(f => f.endsWith('.md')) + .map(f => f.replace('.md', '')) + ); + } else { + _gsdCommandRoster = new Set(); + if (!_gsdCommandRosterWarned && !process.env.GSD_TEST_MODE) { + _gsdCommandRosterWarned = true; + console.warn( + `WARNING: GSD command roster not found at ${gsdSrc}. ` + + `Gemini /gsd- → /gsd: conversion will be a no-op. ` + + `This usually means the package was installed without commands/gsd/.` + ); + } + } + return _gsdCommandRoster; +} + +// Test-only: reset the cached roster. Exported via GSD_TEST_MODE bundle below. +function _resetGsdCommandRoster() { + _gsdCommandRoster = null; + _gsdCommandRosterWarned = false; +} + +function convertSlashCommandsToGeminiMentions(content) { + const commands = getGsdCommandRoster(); + // Defense in depth: regex boundary AND roster lookup must both agree. + // + // - Lookbehind `(? { + return commands.has(commandName) ? `/gsd:${commandName}` : match; + }); +} + +function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) { + // Apply Gemini-specific slash command namespacing + let converted = convertSlashCommandsToGeminiMentions(content); + // Strip HTML subscript tags — terminals can't render them. Done before + // TOML conversion so the prompt body of a command file is also clean. + converted = stripSubTags(converted); + + if (isCommand) { + // Convert to Gemini TOML format + converted = convertClaudeToGeminiToml(converted); + } + + return converted; +} + function convertClaudeToGeminiAgent(content) { if (!content.startsWith('---')) return content; @@ -4409,7 +4487,9 @@ function convertClaudeToGeminiAgent(content) { // Runtime-neutral agent name replacement (#766) const neutralBody = neutralizeAgentReferences(escapedBody, 'GEMINI.md'); - return `---\n${newFrontmatter}\n---${stripSubTags(neutralBody)}`; + // Apply Gemini-specific transformations (slash commands + sub-tag stripping) + const geminiBody = convertClaudeToGeminiMarkdown(neutralBody); + return `---\n${newFrontmatter}\n---${geminiBody}`; } function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null } = {}) { @@ -5376,10 +5456,13 @@ function restoreUserArtifacts(destDir, saved) { * @param {string} destDir - Destination directory * @param {string} pathPrefix - Path prefix for file references * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex') + * @param {boolean} isCommand - Whether the source is a command directory + * @param {boolean} isGlobal - Whether the install is global */ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false) { const isOpencode = runtime === 'opencode'; const isKilo = runtime === 'kilo'; + const isGemini = runtime === 'gemini'; const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; const isAntigravity = runtime === 'antigravity'; @@ -5428,17 +5511,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand ? convertClaudeToKiloFrontmatter(content) : convertClaudeToOpencodeFrontmatter(content); fs.writeFileSync(destPath, content); - } else if (runtime === 'gemini') { - if (isCommand) { - // Convert to TOML for Gemini (strip tags — terminals can't render subscript) - content = stripSubTags(content); - const tomlContent = convertClaudeToGeminiToml(content); - // Replace extension with .toml - const tomlPath = destPath.replace(/\.md$/, '.toml'); - fs.writeFileSync(tomlPath, tomlContent); - } else { - fs.writeFileSync(destPath, content); - } + } else if (isGemini) { + // Apply Gemini-specific Markdown transformations (slash commands, TOML) + const processed = convertClaudeToGeminiMarkdown(content, { isCommand }); + const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath; + fs.writeFileSync(finalPath, processed); } else if (isCodex) { content = convertClaudeToCodexMarkdown(content); fs.writeFileSync(destPath, content); @@ -6660,8 +6737,10 @@ function reportLocalPatches(configDir, runtime = 'claude') { if (meta.files && meta.files.length > 0) { const reapplyCommand = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot') ? '/gsd-reapply-patches' - : runtime === 'codex' - ? '$gsd-reapply-patches' + : runtime === 'gemini' + ? '/gsd:reapply-patches' + : runtime === 'codex' + ? '$gsd-reapply-patches' : runtime === 'cursor' ? 'gsd-reapply-patches (mention the skill name)' : '/gsd-reapply-patches'; @@ -7863,6 +7942,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS let command = '/gsd-new-project'; if (runtime === 'opencode') command = '/gsd-new-project'; if (runtime === 'kilo') command = '/gsd-new-project'; + if (runtime === 'gemini') command = '/gsd:new-project'; if (runtime === 'codex') command = '$gsd-new-project'; if (runtime === 'copilot') command = '/gsd-new-project'; if (runtime === 'antigravity') command = '/gsd-new-project'; @@ -8560,6 +8640,9 @@ if (process.env.GSD_TEST_MODE) { getCodexSkillAdapterHeader, convertClaudeCommandToCursorSkill, convertClaudeAgentToCursorAgent, + convertClaudeToGeminiMarkdown, + convertSlashCommandsToGeminiMentions, + _resetGsdCommandRoster, convertClaudeToGeminiAgent, convertClaudeAgentToCodexAgent, generateCodexAgentToml, diff --git a/tests/gemini-namespacing.test.cjs b/tests/gemini-namespacing.test.cjs new file mode 100644 index 000000000..f83ceb92c --- /dev/null +++ b/tests/gemini-namespacing.test.cjs @@ -0,0 +1,177 @@ +/** + * Regression tests for Gemini namespacing (PR #2768) + * + * Verifies that slash commands are correctly converted to colon format (/gsd:) + * while preserving URLs, file paths, and agent names. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + convertSlashCommandsToGeminiMentions, + convertClaudeToGeminiMarkdown, + _resetGsdCommandRoster, + install +} = require('../bin/install.js'); + +/** + * Minimal parser for the simple TOML emitted by convertClaudeToGeminiToml — + * exactly two top-level keys (`description` and `prompt`), each a JSON-quoted + * string. Throws on unparseable lines so a regression in the emitter shape + * fails loudly rather than silently mis-parsing. + */ +function parseGeminiCommandToml(toml) { + const result = {}; + for (const rawLine of toml.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + const match = line.match(/^([a-z_]+)\s*=\s*(.*)$/); + if (!match) throw new Error(`Unparseable TOML line: ${rawLine}`); + const [, key, value] = match; + // Values are JSON-encoded strings — JSON.parse handles all escapes. + result[key] = JSON.parse(value); + } + return result; +} + +describe('Gemini Slash Command Namespacing (Regex)', () => { + test('converts simple slash commands', () => { + const input = 'Run /gsd-plan-phase to start.'; + const expected = 'Run /gsd:plan-phase to start.'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), expected); + }); + + test('preserves URLs with /gsd- in them', () => { + const input = 'Documentation: https://example.com/gsd-tools/info'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input); + }); + + // The roster check is the safety property: a token like /gsd-plan-phase IS + // a known command name, but when it appears inside a URL path it must NOT + // be converted. This pins that the roster check actually fires — a regex-only + // approach without a roster would convert this incorrectly. + test('preserves URLs even when path contains a KNOWN command name', () => { + const input = 'See https://example.com/gsd-plan-phase for context.'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input); + }); + + test('preserves sub-paths: bin/gsd-tools.cjs', () => { + const input = 'See bin/gsd-tools.cjs for details.'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input); + }); + + test('preserves sub-paths even when leaf is a KNOWN command name', () => { + // bin/gsd-plan-phase looks like a known command but is a file path. + // The leading / on a sub-path follows a non-slash char so the regex + // boundary is the safety net here, not the roster. + const input = 'Reference bin/gsd-plan-phase for details.'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input); + }); + + test('preserves root-relative paths with extensions: /gsd-tools.cjs', () => { + const input = 'Load /gsd-tools.cjs now.'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input); + }); + + test('preserves agent names: gsd-planner', () => { + const input = 'The gsd-planner agent will help you.'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input); + }); + + test('converts commands in backticks', () => { + const input = 'Run `/gsd-new-project` in a terminal.'; + const expected = 'Run `/gsd:new-project` in a terminal.'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), expected); + }); + + test('converts commands ending with punctuation', () => { + const input = 'Run /gsd-help. Or /gsd-scan!'; + const expected = 'Run /gsd:help. Or /gsd:scan!'; + assert.strictEqual(convertSlashCommandsToGeminiMentions(input), expected); + }); + + test('roster has loaded — non-empty (would otherwise silently no-op all conversions)', () => { + _resetGsdCommandRoster(); + // First conversion call lazily populates the roster. If it returned an + // empty Set (because commands/gsd/ was not found), every conversion + // becomes a no-op — exactly the bug this code exists to prevent. + const result = convertSlashCommandsToGeminiMentions('Run /gsd-plan-phase.'); + assert.strictEqual(result, 'Run /gsd:plan-phase.', + 'Roster failed to load — all /gsd- conversions would silently no-op'); + }); +}); + +describe('Gemini Markdown Processor', () => { + test('handles command to TOML conversion', () => { + const input = '---\ndescription: Test\n---\nRun /gsd-help.'; + const result = convertClaudeToGeminiMarkdown(input, { isCommand: true }); + const parsed = parseGeminiCommandToml(result); + assert.equal(parsed.description, 'Test', 'description must round-trip through TOML'); + assert.match(parsed.prompt, /\/gsd:help/, 'prompt must contain namespaced command'); + assert.doesNotMatch(parsed.prompt, /\/gsd-help/, 'prompt must not retain hyphen form'); + }); + + test('strips tags from Gemini markdown output (#2768 regression)', () => { + // The pre-refactor command path called stripSubTags before TOML conversion. + // After centralizing through convertClaudeToGeminiMarkdown, sub tags must + // still be stripped — terminals can't render HTML subscript. + const input = 'Run tiny /gsd-help now.'; + const result = convertClaudeToGeminiMarkdown(input, { isCommand: false }); + assert.doesNotMatch(result, /|<\/sub>/, ' tags must be stripped'); + assert.match(result, /\/gsd:help/, 'slash command must still be converted'); + }); +}); + +describe('Gemini Install (Behavioral)', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-gemini-test-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install creates correct directory structure for Gemini', () => { + // Run install in silent mode + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'gemini'); + } finally { + console.log = oldLog; + } + + const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd'); + assert.ok(fs.existsSync(commandsDir), `Commands should be in ${commandsDir}`); + const agentsDir = path.join(tmpDir, '.gemini', 'agents'); + assert.ok(fs.existsSync(agentsDir), 'Agents should be installed'); + + // Structurally verify a real installed command artifact: parse the TOML + // and assert the prompt body has been namespaced. A directory-only check + // would pass even if every conversion silently no-op'd. + const planPhaseToml = path.join(commandsDir, 'plan-phase.toml'); + assert.ok(fs.existsSync(planPhaseToml), 'plan-phase.toml must be installed'); + const parsed = parseGeminiCommandToml(fs.readFileSync(planPhaseToml, 'utf8')); + assert.equal(typeof parsed.prompt, 'string', 'plan-phase.toml must have a prompt'); + // The plan-phase prompt cross-references other GSD commands; pin that at + // least one of those references survived as a colon-namespaced mention. + assert.match(parsed.prompt, /\/gsd:[a-z][a-z0-9-]*/, + 'installed plan-phase.toml prompt must contain at least one /gsd: reference'); + assert.doesNotMatch(parsed.prompt, /(?