fix(install): apply path replacement in copyCommandsAsClaudeSkills (#1677)

* ci: drop Windows runner, add static hardcoded-path detection

Replace the Windows CI runner with a static analysis test that catches
the same class of platform-specific path bugs (C:\, /home/, /Users/,
/tmp/) without requiring an actual Windows machine.

- tests/hardcoded-paths.test.cjs: new static scanner that checks string
  literals in all source JS/CJS files for hardcoded platform paths;
  runs on Linux/macOS in <100ms and fires on every PR
- .github/workflows/test.yml: remove windows-latest from matrix; switch
  macOS smoke-test runner from Node 22 → Node 24 (the declared standard)
- package.json: bump engines.node from >=20.0.0 to >=22.0.0 (Node 20
  reached EOL April 2026)

Matrix goes from 4 runners → 3 runners per run:
  ubuntu/22  ubuntu/24  macos/24

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install): apply path replacement in copyCommandsAsClaudeSkills (#1653)

copyCommandsAsClaudeSkills received pathPrefix as a parameter but never
used it — all 51 SKILL.md files kept hardcoded ~/.claude/ paths even on
local (per-project) installs, causing every skill's @-file references
to resolve to a nonexistent global directory.

Add the same three regex replacements that copyCommandsAsCodexSkills
already applies: ~/.claude/ → pathPrefix, $HOME/.claude/ → pathPrefix,
./.claude/ → ./getDirName(runtime)/.

Closes #1653

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-04 15:12:58 -04:00
committed by GitHub
parent a6457a7688
commit 7185803543
2 changed files with 78 additions and 0 deletions

View File

@@ -3740,6 +3740,9 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti
fs.mkdirSync(skillDir, { recursive: true });
let content = fs.readFileSync(srcPath, 'utf8');
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
content = processAttribution(content, getCommitAttribution('claude'));
content = convertClaudeCommandToClaudeSkill(content, skillName);

View File

@@ -272,6 +272,81 @@ describe('copyCommandsAsClaudeSkills', () => {
});
});
// ─── Path replacement in Claude skills (#1653) ────────────────────────────────
describe('copyCommandsAsClaudeSkills path replacement (#1653)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-claude-path-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('replaces ~/.claude/ paths with pathPrefix on local install', () => {
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(
path.join(srcDir, 'manager.md'),
[
'---',
'name: gsd:manager',
'description: Manager command',
'---',
'',
'<execution_context>',
'@~/.claude/get-shit-done/workflows/manager.md',
'@~/.claude/get-shit-done/references/ui-brand.md',
'</execution_context>',
].join('\n')
);
const skillsDir = path.join(tmpDir, 'skills');
const localPrefix = '/Users/test/myproject/.claude/';
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', localPrefix, 'claude', false);
const content = fs.readFileSync(path.join(skillsDir, 'gsd-manager', 'SKILL.md'), 'utf8');
assert.ok(!content.includes('~/.claude/'), 'no hardcoded ~/.claude/ paths remain');
assert.ok(content.includes(localPrefix + 'get-shit-done/workflows/manager.md'), 'path rewritten to local prefix');
assert.ok(content.includes(localPrefix + 'get-shit-done/references/ui-brand.md'), 'reference path rewritten');
});
test('replaces $HOME/.claude/ paths with pathPrefix', () => {
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(
path.join(srcDir, 'debug.md'),
'---\nname: gsd:debug\ndescription: Debug\n---\n\n@$HOME/.claude/get-shit-done/workflows/debug.md'
);
const skillsDir = path.join(tmpDir, 'skills');
const localPrefix = '/tmp/project/.claude/';
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', localPrefix, 'claude', false);
const content = fs.readFileSync(path.join(skillsDir, 'gsd-debug', 'SKILL.md'), 'utf8');
assert.ok(!content.includes('$HOME/.claude/'), 'no $HOME/.claude/ paths remain');
assert.ok(content.includes(localPrefix + 'get-shit-done/workflows/debug.md'), 'path rewritten');
});
test('global install preserves $HOME/.claude/ when pathPrefix matches', () => {
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(
path.join(srcDir, 'next.md'),
'---\nname: gsd:next\ndescription: Next\n---\n\n@~/.claude/get-shit-done/workflows/next.md'
);
const skillsDir = path.join(tmpDir, 'skills');
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8');
assert.ok(content.includes('$HOME/.claude/get-shit-done/workflows/next.md'), 'global paths use $HOME form');
assert.ok(!content.includes('~/.claude/'), '~/ form replaced with $HOME/ form');
});
});
// ─── Legacy cleanup during install ──────────────────────────────────────────
describe('Legacy commands/gsd/ cleanup', () => {