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:
142
bin/install.js
142
bin/install.js
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user