Files
msd-core/scripts/fix-slash-commands.cjs
Cristian Uibar 05316369ae 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.
2026-05-16 13:09:58 -04:00

148 lines
5.3 KiB
JavaScript

'use strict';
/**
* One-shot script + library: bidirectional GSD slash-command namespace normalizer.
*
* - Default direction (transformContent): retired /gsd-<cmd> → /gsd:<cmd>
* (keeps monorepo sources, docs, and workflows in the active colon form).
* - Reverse direction (transformContentToHyphen): /gsd:<cmd> / gsd:<cmd> → gsd-<cmd>
* (used during skill installation for runtimes that register skills under the
* canonical hyphen form established in #2808).
*
* Both directions only rewrite known commands from `commands/gsd/*.md` (longest-first
* matching + word-boundary safety). Non-commands (gsd-sdk, gsd-tools, etc.) are
* intentionally left untouched.
*
* The transforms are pure and exported for use by the installer and tests.
*/
const fs = require('node:fs');
const path = require('node:path');
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const SEARCH_DIRS = [
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib'),
path.join(__dirname, '..', 'get-shit-done', 'workflows'),
path.join(__dirname, '..', 'get-shit-done', 'references'),
path.join(__dirname, '..', 'get-shit-done', 'templates'),
path.join(__dirname, '..', 'get-shit-done', 'contexts'),
path.join(__dirname, '..', 'commands', 'gsd'),
path.join(__dirname, '..', 'agents'),
path.join(__dirname, '..', 'hooks'),
];
const TOP_LEVEL_FILES = [
path.join(__dirname, '..', '.clinerules'),
];
const SKIP_DIRS = new Set(['node_modules', 'dist', '.turbo']);
const EXTENSIONS = new Set(['.md', '.cjs', '.js', '.ts', '.tsx']);
// Test files contain intentional fixture strings (e.g. inputs the sanitizer
// is expected to strip). Rewriting them changes test semantics.
function isTestFile(name) {
return /\.test\.(c?js|tsx?)$/.test(name);
}
function buildPattern(cmdNames) {
// Empty input would compile `/gsd-()(?=[^a-zA-Z0-9_-]|$)/g`, which the regex
// engine still matches at any `/gsd-` token followed by a non-word boundary
// (e.g. EOL, whitespace, punctuation) — rewriting it to a stray `/gsd:`.
// Short-circuit so the caller can no-op on a missing/empty registry rather
// than perform an unintended broad rewrite.
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
const sorted = [...cmdNames].sort((a, b) => b.length - a.length); // longest first to avoid partial matches
return new RegExp(`/gsd-(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
}
/**
* Pure transform: rewrite retired `/gsd-<cmd>` to `/gsd:<cmd>` for the given command names.
* Returns the rewritten string. Identifiers not in `cmdNames` (e.g. `/gsd-sdk`,
* `/gsd-tools`) are left untouched.
*/
function transformContent(src, cmdNames) {
const pattern = buildPattern(cmdNames);
if (!pattern) return src;
return src.replace(pattern, (_, cmd) => `/gsd:${cmd}`);
}
/**
* Build regex for the reverse direction (colon form → hyphen form).
* Matches both "gsd:cmd" and "/gsd:cmd" (the leading / is preserved automatically
* because it is not part of the match). Uses longest-first ordering plus
* bidirectional word-boundary safety (negative lookbehind on the left, lookahead
* on the right) so matches only occur at token boundaries.
*/
function buildColonPattern(cmdNames) {
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
return new RegExp(`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
}
/**
* Pure transform (reverse): rewrite `/gsd:<cmd>` / `gsd:<cmd>` to hyphen form
* for known GSD commands.
*
* Non-command identifiers (e.g. gsd-sdk, gsd-tools) are left untouched, matching
* the safety contract of the forward transform.
*/
function transformContentToHyphen(src, cmdNames) {
const pattern = buildColonPattern(cmdNames);
if (!pattern) return src;
return src.replace(pattern, (_, cmd) => `gsd-${cmd}`);
}
function readCmdNames() {
return fs.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''));
}
function processFile(file, cmdNames) {
const pattern = buildPattern(cmdNames);
if (!pattern) return;
let src;
try { src = fs.readFileSync(file, 'utf-8'); } catch { return; }
const replaced = transformContent(src, cmdNames);
if (replaced !== src) {
fs.writeFileSync(file, replaced, 'utf-8');
const count = (src.match(pattern) || []).length;
console.log(` ${count} replacements: ${path.relative(path.join(__dirname, '..'), file)}`);
}
}
function processDir(dir, cmdNames) {
const pattern = buildPattern(cmdNames);
if (!pattern) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
processDir(full, cmdNames);
} else if (EXTENSIONS.has(path.extname(e.name)) && !isTestFile(e.name)) {
processFile(full, cmdNames);
}
}
}
if (require.main === module) {
const cmdNames = readCmdNames();
for (const dir of SEARCH_DIRS) {
processDir(dir, cmdNames);
}
for (const file of TOP_LEVEL_FILES) {
processFile(file, cmdNames);
}
console.log('Done.');
}
module.exports = {
transformContent,
transformContentToHyphen,
buildPattern,
buildColonPattern,
readCmdNames,
SKIP_DIRS
};