fix(3583): normalize retired colon-form commands in generated Claude/Qwen/Hermes SKILL.md bodies (#3629)

* Add first-class grok runtime support (maps to ~/.agents); wire installer, runtime-homes.cjs and sync-skills; update Grok Build engine in local ~/.agents to latest; record session progress in discussion doc

* Normalize gsd colon references to hyphen in generated Claude SKILL.md bodies using the shared transformer. Fixes #3583.

* Refine #3583 implementation after review: cache command names, improve tests, clean up comments

* Harden gsd colon-to-hyphen transformer with bidirectional word boundaries and body-only regression guard

* Track quick-wins batch status and local session notes for #3583/#3579 handoff

* Port installer robustness (hoist copyLibDir + selective Codex hooks) from 3579 to make Codex tests pass on this branch. Fixes ReferenceError and prevents extra hook pollution in Codex installs.

* Restore #3583 transformer wiring and Codex .sh GSD_VERSION branch lost in 50ff8f17 port

Commit 50ff8f17 ('Port installer robustness from #3579') accidentally reverted:
- the top-level require of transformContentToHyphen/readGsdCommandNames
- the body normalization inside convertClaudeCommandToClaudeSkill
- the Codex hook loop's .sh branch with {{GSD_VERSION}} substitution

These were the actual #3583 fix and the Codex half of the #2136 invariant.
Failing tests fixed: bug-2808-skill-hyphen-name, claude-skills-migration #3583
case, bug-2136 Codex .sh substitution.

* Exempt 'sync-skills' slug from docs-parity check (skill dir name in path references)

gsd-sync-skills is an installed Claude skill name and a workflow file but
not a registered slash command. The docs-parity regex catches /gsd-sync-skills
from filesystem path references like ~/.agents/skills/gsd-sync-skills/ in
docs/discussions/grok-build-support-2026-05.md.

Adding to INTERNAL_COMPONENT_SLUGS matches the existing exemption pattern
for 'statusline', 'workspaces', 'graphify-update', etc.

* Restrict hooks/lib/ install to hook-enabled runtimes and managed allowlist

Codex/Copilot/Cursor/Windsurf/Trae/Cline already skip the hooks block but were still copying hooks/lib/ helpers, contradicting the downstream Codex comment. Gate the call on the same runtime check and pass GSD_HOOK_LIB_FILES so install scope matches the uninstall/manifest scope.
This commit is contained in:
Cristian Uibar
2026-05-16 20:09:58 +03:00
committed by GitHub
parent 7f8b5701bf
commit 05316369ae
11 changed files with 631 additions and 26 deletions

View File

@@ -20,6 +20,15 @@ const {
projectCodexHookTomlCommand,
} = require('../get-shit-done/bin/lib/shell-command-projection.cjs');
// Bidirectional GSD slash-command namespace transformer (#3583).
// Required at module scope so the command list can be computed once per install
// and passed down to convertClaudeCommandToClaudeSkill, avoiding repeated
// fs.readdirSync + RegExp work for every skill.
const {
transformContentToHyphen,
readCmdNames: readGsdCommandNames,
} = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'));
// Colors
const cyan = '\x1b[36m';
const green = '\x1b[32m';
@@ -50,6 +59,10 @@ function isCodexHooksFeatureKey(key) {
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by get-shit-done installer -->';
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
const CODEX_AGENT_SANDBOX = {
'gsd-executor': 'workspace-write',
'gsd-planner': 'workspace-write',
@@ -1665,10 +1678,18 @@ function skillFrontmatterName(skillDirName) {
* Emits `name: gsd-<cmd>` (hyphen) so Skill(skill="gsd-<cmd>") calls and
* tab autocomplete use the canonical command namespace.
*/
function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null) {
function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, cmdNames = null) {
const { frontmatter, body } = extractFrontmatterAndBody(content);
if (!frontmatter) return content;
// #3583: rewrite any /gsd:<cmd> or gsd:<cmd> in the body to the canonical
// hyphen form (gsd-<cmd>) so installed SKILL.md bodies match the hyphen
// `name:` Claude Code (and Qwen/Hermes) register under (#2808). `cmdNames`
// is optional and pre-computed by the caller for performance; direct test
// calls fall back to reading the list.
const names = cmdNames || readGsdCommandNames();
const normalizedBody = transformContentToHyphen(body, names);
const description = extractFrontmatterField(frontmatter, 'description') || '';
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
const agent = extractFrontmatterField(frontmatter, 'agent');
@@ -1694,7 +1715,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null) {
if (toolsBlock) fm += toolsBlock;
fm += '---';
return `${fm}\n${body}`;
return `${fm}\n${normalizedBody}`;
}
/**
@@ -5895,6 +5916,11 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti
fs.mkdirSync(skillsDir, { recursive: true });
// Live command names for the colon→hyphen body transform (#3583), computed
// once per install instead of inside convertClaudeCommandToClaudeSkill where
// it would re-scan commands/gsd for every skill.
const cmdNames = readGsdCommandNames();
// #2973 (CR follow-up on #3003): preserve user-generated skills across the
// wipe-and-replace. `gsd-dev-preferences/SKILL.md` is written by the user
// via `/gsd-profile-user --refresh`; it is NOT shipped by the npm package,
@@ -5985,7 +6011,7 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti
content = content.replace(/\.claude\//g, '.hermes/');
}
content = processAttribution(content, getCommitAttribution(runtime));
content = convertClaudeCommandToClaudeSkill(content, skillName, runtime);
content = convertClaudeCommandToClaudeSkill(content, skillName, runtime, cmdNames);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
}
@@ -6867,6 +6893,33 @@ function uninstall(isGlobal, runtime = 'claude') {
removedCount++;
console.log(` ${green}✓${reset} Removed ${hookCount} GSD hooks`);
}
// Remove only the GSD-managed files from hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
// hooks/lib/ lives inside the user's runtime hooks directory (shared space) and
// may contain user-owned custom helpers. We must not recursively delete the dir.
const hooksLibDir = path.join(hooksDir, 'lib');
if (fs.existsSync(hooksLibDir)) {
let removedLibFiles = 0;
for (const file of GSD_HOOK_LIB_FILES) {
const filePath = path.join(hooksLibDir, file);
try {
fs.unlinkSync(filePath);
removedLibFiles++;
} catch (_) {
// Ignore missing files (best effort, non-fatal)
}
}
// Only remove the directory itself if it is now empty (preserve any user files)
try {
fs.rmdirSync(hooksLibDir);
} catch (_) {
// Directory not empty or other error — leave it alone
}
if (removedLibFiles > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${removedLibFiles} hooks/lib/ helper(s)`);
}
}
}
// 5. Remove GSD package.json (CommonJS mode marker)
@@ -7486,6 +7539,16 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
manifest.files['hooks/' + file] = fileHash(path.join(hooksDir, file));
}
}
// Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
// to git-cmd.js (validate-commit classifier) and gsd-graphify-rebuild.sh.
const hooksLibDir = path.join(hooksDir, 'lib');
if (fs.existsSync(hooksLibDir)) {
for (const file of fs.readdirSync(hooksLibDir)) {
if (GSD_HOOK_LIB_FILES.includes(file)) {
manifest.files['hooks/lib/' + file] = fileHash(path.join(hooksLibDir, file));
}
}
}
}
}
@@ -7749,6 +7812,36 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
// Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
// Defined early so it is visible to both the main and Codex code paths.
// `allowlist` (when non-empty) restricts copying to the named top-level entries,
// keeping install scope aligned with GSD_HOOK_LIB_FILES (which uninstall/manifest manage).
const copyLibDir = (sDir, dDir, allowlist = []) => {
const allowed = allowlist.length > 0 ? new Set(allowlist) : null;
for (const entry of fs.readdirSync(sDir)) {
if (allowed && !allowed.has(entry)) continue;
const s = path.join(sDir, entry);
const d = path.join(dDir, entry);
let st;
try { st = fs.lstatSync(s); } catch (_) { continue; }
if (st.isSymbolicLink()) continue; // defense-in-depth
if (st.isDirectory()) {
fs.mkdirSync(d, { recursive: true });
copyLibDir(s, d);
} else if (entry.endsWith('.sh')) {
let content = fs.readFileSync(s, 'utf8');
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(d, content);
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
} else {
fs.copyFileSync(s, d);
if (entry.endsWith('.js')) {
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
}
}
}
};
// Get the target directory based on runtime and install type.
// Cline local installs write to the project root (like Claude Code) — .clinerules
// lives at the root, not inside a .cline/ subdirectory.
@@ -8678,6 +8771,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
}
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
// Codex/Copilot/Cursor/Windsurf/Trae/Cline skip hooks entirely, so they must not
// receive the hooks/lib/ helpers either — otherwise the Codex comment downstream
// ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
const hooksLibSrc = path.join(src, 'hooks', 'lib');
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
fs.mkdirSync(hooksLibDest, { recursive: true });
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
}
// Clear stale update cache so next session re-evaluates hook versions
// Cache lives at ~/.cache/gsd/ (see hooks/gsd-check-update.js line 35-36)
const updateCacheFile = path.join(os.homedir(), '.cache', 'gsd', 'gsd-update-check.json');
@@ -8938,15 +9043,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
}
// Copy hook files that are referenced by Codex hook configuration (#2153)
// The main hook-copy block is gated to non-Codex runtimes, but Codex registers
// gsd-check-update.js through hooks config — the file must physically exist.
// Copy only the hook files that Codex actually registers via its hook configuration (#2153).
// Codex primarily needs gsd-check-update.js for the SessionStart update-check hook.
// We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
// in this change (graphify auto-update support for Codex is out of scope for #3579).
const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js'];
const codexHooksSrc = path.join(src, 'hooks', 'dist');
if (fs.existsSync(codexHooksSrc)) {
const codexHooksDest = path.join(targetDir, 'hooks');
fs.mkdirSync(codexHooksDest, { recursive: true });
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
for (const entry of fs.readdirSync(codexHooksSrc)) {
if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
const srcFile = path.join(codexHooksSrc, entry);
if (!fs.statSync(srcFile).isFile()) continue;
const destFile = path.join(codexHooksDest, entry);
@@ -8958,18 +9066,20 @@ function install(isGlobal, runtime = 'claude', options = {}) {
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else {
if (entry.endsWith('.sh')) {
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else {
fs.copyFileSync(srcFile, destFile);
}
} else if (entry.endsWith('.sh')) {
// #2136: any .sh hook reaching this loop must have {{GSD_VERSION}}
// stamped so installed scripts carry a concrete version header and
// stale-hook detection keeps working across upgrades. The current
// CODEX_HOOKS_TO_COPY allowlist excludes .sh files, so this branch
// is defensive — it preserves the invariant if the allowlist is
// extended later (e.g. to ship gsd-graphify-update.sh for Codex).
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
}
}
console.log(` ${green}✓${reset} Installed hooks`);
console.log(` ${green}✓${reset} Installed hooks (Codex)`);
}
// Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag