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
12 changes: 9 additions & 3 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions test/setup-hook-canonical-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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<string, string> = { 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);
Expand Down