Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -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/<other-name>, 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.
Expand Down Expand Up @@ -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"
Expand Down
123 changes: 123 additions & 0 deletions test/setup-claude-runtime-alias.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});