diff --git a/setup b/setup index 275236cd3..2a2fe4966 100755 --- a/setup +++ b/setup @@ -606,6 +606,44 @@ link_claude_root_skill_alias() { _print_windows_copy_note_once } +# Claude skill bodies still reference ~/.claude/skills/gstack/ for runtime +# assets. If the checkout is installed as ~/.claude/skills/, keep a +# runtime-only gstack alias so those paths resolve without rewriting generated +# SKILL.md files or dirtying the checkout. +ensure_claude_runtime_alias() { + local gstack_dir="$1" + local skills_dir="$2" + local install_name + local install_parent + local alias + local existing_real + local claude_skills_dir + + claude_skills_dir="$HOME/.claude/skills" + [ "$skills_dir" = "$claude_skills_dir" ] || return 0 + + install_name="$(basename "$gstack_dir")" + [ "$install_name" = "gstack" ] && return 0 + + install_parent="$(dirname "$gstack_dir")" + [ "$install_parent" = "$skills_dir" ] || return 0 + + alias="$skills_dir/gstack" + if [ -d "$alias" ] && [ ! -L "$alias" ]; then + existing_real=$(cd "$alias" 2>/dev/null && pwd -P || echo "") + if [ -n "$existing_real" ] && [ "$existing_real" != "$gstack_dir" ]; then + log " warning: $alias already exists as a separate install; not replacing it" + return 0 + fi + fi + + if [ -L "$alias" ] || [ ! -e "$alias" ]; then + _link_or_copy "$gstack_dir" "$alias" + log " linked runtime alias: $alias -> $gstack_dir" + _print_windows_copy_note_once + fi +} + # ─── 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. @@ -982,6 +1020,7 @@ fi if [ "$INSTALL_CLAUDE" -eq 1 ]; then if [ "$SKILLS_BASENAME" = "skills" ]; then + ensure_claude_runtime_alias "$INSTALL_GSTACK_DIR" "$INSTALL_SKILLS_DIR" # Clean up stale symlinks from the opposite prefix mode if [ "$SKILL_PREFIX" -eq 1 ]; then cleanup_old_claude_symlinks "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" diff --git a/test/setup-claude-runtime-alias.test.ts b/test/setup-claude-runtime-alias.test.ts new file mode 100644 index 000000000..656eacecb --- /dev/null +++ b/test/setup-claude-runtime-alias.test.ts @@ -0,0 +1,123 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); + +function extractFunction(src: string, name: string): string { + const start = src.indexOf(`${name}() {`); + if (start < 0) throw new Error(`Could not locate ${name}() in setup`); + const end = src.indexOf('\n}\n', start); + if (end < 0) throw new Error(`Could not locate end of ${name}() in setup`); + return src.slice(start, end + 2); +} + +describe('setup: Claude runtime alias for non-gstack install dirs', () => { + test('setup calls the runtime alias helper before linking Claude skills', () => { + const helperIdx = SETUP.indexOf('ensure_claude_runtime_alias "$INSTALL_GSTACK_DIR" "$INSTALL_SKILLS_DIR"'); + const linkIdx = SETUP.indexOf('link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"'); + + expect(helperIdx).toBeGreaterThan(-1); + expect(linkIdx).toBeGreaterThan(-1); + expect(helperIdx).toBeLessThan(linkIdx); + }); + + test.skipIf(process.platform === 'win32')('helper creates skills/gstack -> install dir when install basename is not gstack', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-runtime-alias-')); + try { + const skillsDir = path.join(tmp, '.claude', 'skills'); + const installDir = path.join(skillsDir, 'gstack-dev'); + fs.mkdirSync(installDir, { recursive: true }); + + const linkHelper = extractFunction(SETUP, '_link_or_copy'); + const noteHelper = extractFunction(SETUP, '_print_windows_copy_note_once'); + const aliasHelper = extractFunction(SETUP, 'ensure_claude_runtime_alias'); + const script = ` + set -e + HOME='${tmp}' + IS_WINDOWS=0 + QUIET=0 + _WINDOWS_COPY_NOTE_PRINTED=0 + log() { echo "$@"; } + ${linkHelper} + ${noteHelper} + ${aliasHelper} + ensure_claude_runtime_alias '${installDir}' '${skillsDir}' + `; + + const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + const alias = path.join(skillsDir, 'gstack'); + expect(fs.lstatSync(alias).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(alias)).toBe(installDir); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === 'win32')('helper ignores non-Claude skills directories', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-runtime-alias-')); + try { + const skillsDir = path.join(tmp, 'skills'); + const installDir = path.join(skillsDir, 'gstack-dev'); + fs.mkdirSync(installDir, { recursive: true }); + + const linkHelper = extractFunction(SETUP, '_link_or_copy'); + const noteHelper = extractFunction(SETUP, '_print_windows_copy_note_once'); + const aliasHelper = extractFunction(SETUP, 'ensure_claude_runtime_alias'); + const script = ` + set -e + HOME='${tmp}' + IS_WINDOWS=0 + QUIET=0 + _WINDOWS_COPY_NOTE_PRINTED=0 + log() { echo "$@"; } + ${linkHelper} + ${noteHelper} + ${aliasHelper} + ensure_claude_runtime_alias '${installDir}' '${skillsDir}' + `; + + const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(fs.existsSync(path.join(skillsDir, 'gstack'))).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === 'win32')('helper leaves canonical skills/gstack installs alone', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-runtime-alias-')); + try { + const skillsDir = path.join(tmp, '.claude', 'skills'); + const installDir = path.join(skillsDir, 'gstack'); + fs.mkdirSync(installDir, { recursive: true }); + + const linkHelper = extractFunction(SETUP, '_link_or_copy'); + const noteHelper = extractFunction(SETUP, '_print_windows_copy_note_once'); + const aliasHelper = extractFunction(SETUP, 'ensure_claude_runtime_alias'); + const script = ` + set -e + HOME='${tmp}' + IS_WINDOWS=0 + QUIET=0 + _WINDOWS_COPY_NOTE_PRINTED=0 + log() { echo "$@"; } + ${linkHelper} + ${noteHelper} + ${aliasHelper} + ensure_claude_runtime_alias '${installDir}' '${skillsDir}' + `; + + const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(fs.lstatSync(installDir).isDirectory()).toBe(true); + expect(fs.lstatSync(installDir).isSymbolicLink()).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +});