fix(setup): plant gstack compat alias when install dir isn't named "gstack"

Every generated SKILL.md preamble hard-references the absolute self-path
~/.claude/skills/gstack/bin/... (config, telemetry, update-check, and all
other bin/asset calls). When gstack is installed at ~/.claude/skills/<name>
with <name> != gstack, setup's per-skill symlinks resolve correctly but those
in-file references point at a non-existent ~/.claude/skills/gstack/ and fail
SILENTLY at skill-invocation time (the preamble swallows the 127 via
`|| echo default`), so every config read falls back to defaults and telemetry
no-ops. Nothing surfaces at install; skills break later in a way that looks
like the skill itself is broken.

Fix: after linking the Claude host skills, plant a sibling compatibility alias
<skills_dir>/gstack -> <install_dir> whenever the install dir basename isn't
"gstack". Zero file edits; the hardcoded paths resolve through the alias.
Claude Code already skips the repo-shaped gstack directory when building the
slash-command list, so the alias is a path-resolution target only, not a
duplicate skill. It no-ops when the install is already named gstack and never
clobbers a real (non-symlink) gstack directory already present.

Adds test/setup-gstack-compat-alias.test.ts covering the static wiring and the
behavioral matrix (non-gstack install plants the alias, gstack install is a
no-op, real dir not clobbered, stale symlink refreshed).

Fixes #1882.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sina 2026-07-10 12:34:43 -07:00
parent 7c9df1c568
commit 33b3913800
2 changed files with 193 additions and 0 deletions

42
setup
View File

@ -606,6 +606,45 @@ link_claude_root_skill_alias() {
_print_windows_copy_note_once
}
# ─── Helper: compatibility alias for installs not named "gstack" ──────────────
# Every generated SKILL.md preamble hard-references the absolute self-path
# ~/.claude/skills/gstack/bin/... (config, telemetry, update-check, and every
# other bin/asset call). When gstack is installed at ~/.claude/skills/<name>
# with <name> != gstack, the per-skill symlinks resolve but those in-file
# references point at a non-existent ~/.claude/skills/gstack/ and fail SILENTLY
# at skill-invocation time (the preamble swallows the 127 via `|| echo default`).
# See issue #1882.
#
# Fix: plant a sibling alias <skills_dir>/gstack -> <install_dir> so the
# hardcoded paths resolve with zero file edits. Claude Code already skips the
# repo-shaped gstack directory when building the slash-command list (see
# link_claude_root_skill_alias above), so this alias is a path-resolution
# target only, not a duplicate skill. When the install is already named gstack
# the references resolve natively and this is a no-op.
link_gstack_compat_alias() {
local install_dir="$1" # actual gstack checkout (INSTALL_GSTACK_DIR)
local skills_dir="$2" # skills parent (INSTALL_SKILLS_DIR)
# Already named gstack — nothing to reconcile.
[ "$(basename "$install_dir")" = "gstack" ] && return 0
local alias="$skills_dir/gstack"
# Never clobber a real (non-symlink) gstack install already living here — its
# own bin/ already satisfies the hardcoded references, and it may be a
# separate global install we must not destroy.
if [ -e "$alias" ] && [ ! -L "$alias" ]; then
return 0
fi
# Idempotently (re)point a name-only/self alias. On Windows _link_or_copy
# falls back to a directory copy (no symlinks without Developer Mode), which
# is heavier but keeps the hardcoded paths resolvable there too.
_link_or_copy "$install_dir" "$alias"
echo " linked compatibility alias: gstack -> $(basename "$install_dir") (install dir not named 'gstack', issue #1882)"
_print_windows_copy_note_once
}
# ─── Helper: remove old unprefixed Claude skill entries ───────────────────────
# Migration: when switching from flat names to gstack- prefixed names,
# clean up stale symlinks or directories that point into the gstack directory.
@ -993,6 +1032,9 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
# Reconcile hardcoded ~/.claude/skills/gstack/ self-paths when the install
# dir isn't literally named "gstack" (issue #1882). No-op when it is.
link_gstack_compat_alias "$INSTALL_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
# Self-healing: re-run gstack-relink to ensure name: fields and directory
# names are consistent with the config. This catches cases where an interrupted
# setup, stale git state, or gen:skill-docs left name: fields out of sync.

View File

@ -0,0 +1,151 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// Regression coverage for issue #1882: every generated SKILL.md preamble
// hard-references ~/.claude/skills/gstack/bin/... When gstack is installed at
// ~/.claude/skills/<name> with <name> != gstack, those references break
// silently at skill-invocation time. `setup` reconciles this with a
// compatibility alias (<skills_dir>/gstack -> <install_dir>) planted by
// link_gstack_compat_alias.
const SETUP_SRC = fs.readFileSync(path.join(import.meta.dir, '..', 'setup'), 'utf-8');
// Pull the bodies of the helpers we need to exercise in isolation. Anchors are
// the function-definition lines; each body ends at the first line that is a
// lone `}` at column 0.
function extractFn(name: string): string {
const start = SETUP_SRC.indexOf(`${name}() {`);
if (start < 0) throw new Error(`Could not locate ${name}() in setup`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (end < 0) throw new Error(`Could not locate end of ${name}() in setup`);
return SETUP_SRC.slice(start, end + 3);
}
describe('setup: link_gstack_compat_alias — static invariants (#1882)', () => {
test('helper is defined', () => {
expect(SETUP_SRC).toContain('link_gstack_compat_alias() {');
});
test('helper is called in the Claude install path with install + skills dirs', () => {
expect(SETUP_SRC).toContain(
'link_gstack_compat_alias "$INSTALL_GSTACK_DIR" "$INSTALL_SKILLS_DIR"',
);
});
test('helper no-ops when the install dir is already named gstack', () => {
const body = extractFn('link_gstack_compat_alias');
expect(body).toContain('= "gstack" ] && return 0');
});
test('helper never clobbers a real (non-symlink) gstack install', () => {
const body = extractFn('link_gstack_compat_alias');
// Must bail before writing when a real dir already occupies the alias slot.
expect(body).toMatch(/\[ ! -L "\$alias" \][\s\S]*return 0/);
});
test('helper routes through _link_or_copy (no raw ln, Windows-safe)', () => {
const body = extractFn('link_gstack_compat_alias');
expect(body).toContain('_link_or_copy "$install_dir" "$alias"');
});
});
// Behavioral matrix: source the three helpers into a Unix shell and drive each
// case. Symlink semantics only make sense off Windows.
describe.skipIf(process.platform === 'win32')(
'setup: link_gstack_compat_alias — behavior',
() => {
function run(
installBasename: string,
preexisting?: 'real-dir' | 'symlink',
): {
ok: boolean;
aliasExists: boolean;
aliasIsSymlink: boolean;
aliasTarget: string | null;
aliasRealContent: string | null;
stdout: string;
} {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-1882-'));
try {
const skillsDir = path.join(tmp, 'skills');
const installDir = path.join(skillsDir, installBasename);
// A minimal install dir with a recognisable bin/ marker.
fs.mkdirSync(path.join(installDir, 'bin'), { recursive: true });
fs.writeFileSync(path.join(installDir, 'bin', 'gstack-config'), 'echo real-install\n');
const alias = path.join(skillsDir, 'gstack');
if (preexisting === 'real-dir') {
fs.mkdirSync(path.join(alias, 'bin'), { recursive: true });
fs.writeFileSync(path.join(alias, 'bin', 'gstack-config'), 'echo pre-existing\n');
} else if (preexisting === 'symlink') {
// stale self-link pointing somewhere else; helper should refresh it
const stale = path.join(tmp, 'stale');
fs.mkdirSync(stale, { recursive: true });
fs.symlinkSync(stale, alias);
}
const helpers = [
extractFn('_link_or_copy'),
extractFn('_print_windows_copy_note_once'),
extractFn('link_gstack_compat_alias'),
].join('\n');
const script = `IS_WINDOWS=0\n_WINDOWS_COPY_NOTE_PRINTED=0\n${helpers}\nlink_gstack_compat_alias "${installDir}" "${skillsDir}"\n`;
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 5000 });
const lst = fs.lstatSync(alias, { throwIfNoEntry: false });
let target: string | null = null;
let realContent: string | null = null;
if (lst?.isSymbolicLink()) target = fs.readlinkSync(alias);
const cfg = path.join(alias, 'bin', 'gstack-config');
if (fs.existsSync(cfg)) realContent = fs.readFileSync(cfg, 'utf-8').trim();
return {
ok: result.status === 0,
aliasExists: lst !== undefined,
aliasIsSymlink: lst?.isSymbolicLink() ?? false,
aliasTarget: target,
aliasRealContent: realContent,
stdout: result.stdout,
};
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
test('non-gstack install dir → alias symlink planted at skills/gstack', () => {
const r = run('i-gstack');
expect(r.ok).toBe(true);
expect(r.aliasIsSymlink).toBe(true);
expect(r.aliasTarget).toMatch(/i-gstack$/);
// The alias resolves the hardcoded ~/.claude/skills/gstack/bin path.
expect(r.aliasRealContent).toBe('echo real-install');
expect(r.stdout).toContain('compatibility alias: gstack -> i-gstack');
});
test('install dir already named gstack → no alias planted (no-op)', () => {
const r = run('gstack');
expect(r.ok).toBe(true);
// The alias slot IS the install dir here, so it exists — but the helper
// must NOT plant a symlink or announce a compatibility alias.
expect(r.aliasIsSymlink).toBe(false);
expect(r.aliasRealContent).toBe('echo real-install');
expect(r.stdout).not.toContain('compatibility alias');
});
test('pre-existing REAL gstack dir → not clobbered', () => {
const r = run('i-gstack', 'real-dir');
expect(r.ok).toBe(true);
expect(r.aliasIsSymlink).toBe(false);
expect(r.aliasRealContent).toBe('echo pre-existing');
});
test('stale gstack symlink → refreshed to this install (idempotent)', () => {
const r = run('i-gstack', 'symlink');
expect(r.ok).toBe(true);
expect(r.aliasIsSymlink).toBe(true);
expect(r.aliasTarget).toMatch(/i-gstack$/);
});
},
);