fix(install): use colon namespace for Gemini slash commands (#2768)
* 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 <noreply@anthropic.com> * fix(install): centralize <sub> 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 <sub> 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 <sub> tags are stripped through the convertClaudeToGeminiMarkdown pipeline. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
99af76b3ba
commit
73b9d1dac0
111
bin/install.js
111
bin/install.js
@@ -4317,6 +4317,84 @@ function stripSubTags(content) {
|
|||||||
* - skills: must be removed (causes validation error)
|
* - skills: must be removed (causes validation error)
|
||||||
* - mcp__* tools: must be excluded (auto-discovered at runtime)
|
* - 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<string>} 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 `(?<![A-Za-z0-9./])` rejects URLs (`example.com/gsd-…`),
|
||||||
|
// sub-paths (`bin/gsd-…`), and root-relative file paths preceded by a
|
||||||
|
// path char. Without it the roster alone is insufficient: a URL like
|
||||||
|
// `https://example.com/gsd-plan-phase` ends in a known command name and
|
||||||
|
// would convert incorrectly.
|
||||||
|
// - `(?!\/)` rejects sub-path continuation (`/gsd-foo/bar`).
|
||||||
|
// - `(?!\.[a-z])` rejects file extensions (`.cjs`, `.md`) but PERMITS
|
||||||
|
// sentence-ending punctuation like `/gsd-help.` because `.` at end of
|
||||||
|
// string or before whitespace is not followed by a lowercase letter.
|
||||||
|
// - Roster lookup ensures only real commands convert — agent names like
|
||||||
|
// `gsd-planner` (no leading slash anyway) and unknown tokens pass through.
|
||||||
|
//
|
||||||
|
// GSD commands are always lowercase, so no case-insensitive flag.
|
||||||
|
return content.replace(/(?<![A-Za-z0-9./])\/gsd-([a-z0-9-]+)(?!\/)(?!\.[a-z])/g, (match, commandName) => {
|
||||||
|
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) {
|
function convertClaudeToGeminiAgent(content) {
|
||||||
if (!content.startsWith('---')) return content;
|
if (!content.startsWith('---')) return content;
|
||||||
|
|
||||||
@@ -4409,7 +4487,9 @@ function convertClaudeToGeminiAgent(content) {
|
|||||||
|
|
||||||
// Runtime-neutral agent name replacement (#766)
|
// Runtime-neutral agent name replacement (#766)
|
||||||
const neutralBody = neutralizeAgentReferences(escapedBody, 'GEMINI.md');
|
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 } = {}) {
|
function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null } = {}) {
|
||||||
@@ -5376,10 +5456,13 @@ function restoreUserArtifacts(destDir, saved) {
|
|||||||
* @param {string} destDir - Destination directory
|
* @param {string} destDir - Destination directory
|
||||||
* @param {string} pathPrefix - Path prefix for file references
|
* @param {string} pathPrefix - Path prefix for file references
|
||||||
* @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex')
|
* @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) {
|
function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false) {
|
||||||
const isOpencode = runtime === 'opencode';
|
const isOpencode = runtime === 'opencode';
|
||||||
const isKilo = runtime === 'kilo';
|
const isKilo = runtime === 'kilo';
|
||||||
|
const isGemini = runtime === 'gemini';
|
||||||
const isCodex = runtime === 'codex';
|
const isCodex = runtime === 'codex';
|
||||||
const isCopilot = runtime === 'copilot';
|
const isCopilot = runtime === 'copilot';
|
||||||
const isAntigravity = runtime === 'antigravity';
|
const isAntigravity = runtime === 'antigravity';
|
||||||
@@ -5428,17 +5511,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
|||||||
? convertClaudeToKiloFrontmatter(content)
|
? convertClaudeToKiloFrontmatter(content)
|
||||||
: convertClaudeToOpencodeFrontmatter(content);
|
: convertClaudeToOpencodeFrontmatter(content);
|
||||||
fs.writeFileSync(destPath, content);
|
fs.writeFileSync(destPath, content);
|
||||||
} else if (runtime === 'gemini') {
|
} else if (isGemini) {
|
||||||
if (isCommand) {
|
// Apply Gemini-specific Markdown transformations (slash commands, TOML)
|
||||||
// Convert to TOML for Gemini (strip <sub> tags — terminals can't render subscript)
|
const processed = convertClaudeToGeminiMarkdown(content, { isCommand });
|
||||||
content = stripSubTags(content);
|
const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath;
|
||||||
const tomlContent = convertClaudeToGeminiToml(content);
|
fs.writeFileSync(finalPath, processed);
|
||||||
// Replace extension with .toml
|
|
||||||
const tomlPath = destPath.replace(/\.md$/, '.toml');
|
|
||||||
fs.writeFileSync(tomlPath, tomlContent);
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(destPath, content);
|
|
||||||
}
|
|
||||||
} else if (isCodex) {
|
} else if (isCodex) {
|
||||||
content = convertClaudeToCodexMarkdown(content);
|
content = convertClaudeToCodexMarkdown(content);
|
||||||
fs.writeFileSync(destPath, content);
|
fs.writeFileSync(destPath, content);
|
||||||
@@ -6660,8 +6737,10 @@ function reportLocalPatches(configDir, runtime = 'claude') {
|
|||||||
if (meta.files && meta.files.length > 0) {
|
if (meta.files && meta.files.length > 0) {
|
||||||
const reapplyCommand = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot')
|
const reapplyCommand = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot')
|
||||||
? '/gsd-reapply-patches'
|
? '/gsd-reapply-patches'
|
||||||
: runtime === 'codex'
|
: runtime === 'gemini'
|
||||||
? '$gsd-reapply-patches'
|
? '/gsd:reapply-patches'
|
||||||
|
: runtime === 'codex'
|
||||||
|
? '$gsd-reapply-patches'
|
||||||
: runtime === 'cursor'
|
: runtime === 'cursor'
|
||||||
? 'gsd-reapply-patches (mention the skill name)'
|
? 'gsd-reapply-patches (mention the skill name)'
|
||||||
: '/gsd-reapply-patches';
|
: '/gsd-reapply-patches';
|
||||||
@@ -7863,6 +7942,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|||||||
let command = '/gsd-new-project';
|
let command = '/gsd-new-project';
|
||||||
if (runtime === 'opencode') command = '/gsd-new-project';
|
if (runtime === 'opencode') command = '/gsd-new-project';
|
||||||
if (runtime === 'kilo') 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 === 'codex') command = '$gsd-new-project';
|
||||||
if (runtime === 'copilot') command = '/gsd-new-project';
|
if (runtime === 'copilot') command = '/gsd-new-project';
|
||||||
if (runtime === 'antigravity') command = '/gsd-new-project';
|
if (runtime === 'antigravity') command = '/gsd-new-project';
|
||||||
@@ -8560,6 +8640,9 @@ if (process.env.GSD_TEST_MODE) {
|
|||||||
getCodexSkillAdapterHeader,
|
getCodexSkillAdapterHeader,
|
||||||
convertClaudeCommandToCursorSkill,
|
convertClaudeCommandToCursorSkill,
|
||||||
convertClaudeAgentToCursorAgent,
|
convertClaudeAgentToCursorAgent,
|
||||||
|
convertClaudeToGeminiMarkdown,
|
||||||
|
convertSlashCommandsToGeminiMentions,
|
||||||
|
_resetGsdCommandRoster,
|
||||||
convertClaudeToGeminiAgent,
|
convertClaudeToGeminiAgent,
|
||||||
convertClaudeAgentToCodexAgent,
|
convertClaudeAgentToCodexAgent,
|
||||||
generateCodexAgentToml,
|
generateCodexAgentToml,
|
||||||
|
|||||||
177
tests/gemini-namespacing.test.cjs
Normal file
177
tests/gemini-namespacing.test.cjs
Normal file
@@ -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 <sub> 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 <sub>tiny</sub> /gsd-help now.';
|
||||||
|
const result = convertClaudeToGeminiMarkdown(input, { isCommand: false });
|
||||||
|
assert.doesNotMatch(result, /<sub>|<\/sub>/, '<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, /(?<![A-Za-z0-9./])\/gsd-plan-phase\b/,
|
||||||
|
'installed plan-phase.toml prompt must not retain unconverted /gsd-plan-phase');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user