diff --git a/setup b/setup index ceb9a0849c..b7c8735376 100755 --- a/setup +++ b/setup @@ -3947,9 +3947,15 @@ CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack" # (setup:1601 TODO), so a CLAUDE_CONFIG_DIR override can name a root that was # never installed. Fall back to where the install actually lives — both are # stable, neither is the running tree, so canonical-only still holds. -if [ ! -x "$CANONICAL_GSTACK_ROOT/bin/gstack-session-update" ] \ - && [ -x "$HOME/.claude/skills/gstack/bin/gstack-session-update" ]; then - CANONICAL_GSTACK_ROOT="$HOME/.claude/skills/gstack" +# The same fallback applies when CLAUDE_CONFIG_DIR is a per-session directory +# whose skills link back to the $HOME install: both resolve to one tree, and +# only the $HOME spelling outlives the session. +_HOME_GSTACK_ROOT="$HOME/.claude/skills/gstack" +if [ -x "$_HOME_GSTACK_ROOT/bin/gstack-session-update" ]; then + if [ ! -x "$CANONICAL_GSTACK_ROOT/bin/gstack-session-update" ] \ + || [ "$(cd "$CANONICAL_GSTACK_ROOT" && pwd -P)" = "$(cd "$_HOME_GSTACK_ROOT" && pwd -P)" ]; then + CANONICAL_GSTACK_ROOT="$_HOME_GSTACK_ROOT" + fi fi # Echo the canonical path for a hook (repo-relative arg); fails when the hook diff --git a/test/setup-hook-canonical-paths.test.ts b/test/setup-hook-canonical-paths.test.ts index db26047e09..4bf806c497 100644 --- a/test/setup-hook-canonical-paths.test.ts +++ b/test/setup-hook-canonical-paths.test.ts @@ -23,6 +23,8 @@ import { describe, test, expect } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; const ROOT = path.resolve(import.meta.dir, '..'); const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); @@ -36,6 +38,72 @@ describe('setup: canonical-only hook paths', () => { ); }); + describe('CANONICAL_GSTACK_ROOT resolution', () => { + const start = setupSrc.indexOf('CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR'); + const end = setupSrc.indexOf('\n fi\nfi\n', start); + const block = setupSrc.slice(start, end + '\n fi\nfi\n'.length); + + function installAt(root: string) { + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'gstack-session-update'), '#!/bin/sh\n', { mode: 0o755 }); + } + + function resolve(home: string, configDir?: string): string { + const env: Record = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home }; + if (configDir !== undefined) env.CLAUDE_CONFIG_DIR = configDir; + const r = spawnSync('bash', ['-c', `${block}printf '%s' "$CANONICAL_GSTACK_ROOT"`], { env, encoding: 'utf-8' }); + expect(r.status).toBe(0); + return r.stdout; + } + + function withHome(fn: (home: string) => void) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-canon-')); + try { + installAt(path.join(home, '.claude', 'skills', 'gstack')); + fn(home); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + } + + test('the extracted block is the whole guard', () => { + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + }); + + test('no CLAUDE_CONFIG_DIR resolves to the $HOME install', () => { + withHome((home) => { + expect(resolve(home)).toBe(path.join(home, '.claude', 'skills', 'gstack')); + }); + }); + + test('a per-session config dir linking back to the $HOME install resolves to the $HOME spelling', () => { + withHome((home) => { + const session = path.join(home, 'session-1234'); + fs.mkdirSync(session); + fs.symlinkSync(path.join(home, '.claude', 'skills'), path.join(session, 'skills')); + expect(resolve(home, session)).toBe(path.join(home, '.claude', 'skills', 'gstack')); + }); + }); + + test('a config dir with its own install keeps that install', () => { + withHome((home) => { + const other = path.join(home, 'other-config'); + installAt(path.join(other, 'skills', 'gstack')); + expect(resolve(home, other)).toBe(path.join(other, 'skills', 'gstack')); + }); + }); + + test('a config dir with no install falls back to the $HOME install', () => { + withHome((home) => { + const empty = path.join(home, 'empty-config'); + fs.mkdirSync(empty); + expect(resolve(home, empty)).toBe(path.join(home, '.claude', 'skills', 'gstack')); + }); + }); + }); + test('_hook_command_path body never references the running tree', () => { const start = setupSrc.indexOf('_hook_command_path() {'); expect(start).toBeGreaterThan(-1);