diff --git a/careful/SKILL.md b/careful/SKILL.md index 678d66c16b..8887400927 100644 --- a/careful/SKILL.md +++ b/careful/SKILL.md @@ -14,7 +14,7 @@ hooks: - matcher: "Bash" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/bin/check-careful.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/careful/bin/check-careful.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/careful/bin/check-careful.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking for destructive commands..." --- diff --git a/careful/SKILL.md.tmpl b/careful/SKILL.md.tmpl index 9d83411f83..19bf04f191 100644 --- a/careful/SKILL.md.tmpl +++ b/careful/SKILL.md.tmpl @@ -19,7 +19,7 @@ hooks: - matcher: "Bash" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/bin/check-careful.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/careful/bin/check-careful.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/careful/bin/check-careful.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking for destructive commands..." sensitive: true --- diff --git a/freeze/SKILL.md b/freeze/SKILL.md index fc82b1bea8..18c5c0fea6 100644 --- a/freeze/SKILL.md +++ b/freeze/SKILL.md @@ -15,12 +15,12 @@ hooks: - matcher: "Edit" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/bin/check-freeze.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking freeze boundary..." - matcher: "Write" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/bin/check-freeze.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking freeze boundary..." --- diff --git a/freeze/SKILL.md.tmpl b/freeze/SKILL.md.tmpl index a1b456e535..7cf3b90cfc 100644 --- a/freeze/SKILL.md.tmpl +++ b/freeze/SKILL.md.tmpl @@ -20,12 +20,12 @@ hooks: - matcher: "Edit" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/bin/check-freeze.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking freeze boundary..." - matcher: "Write" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/bin/check-freeze.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking freeze boundary..." sensitive: true --- diff --git a/guard/SKILL.md b/guard/SKILL.md index e4dff7936f..7680fc3bc8 100644 --- a/guard/SKILL.md +++ b/guard/SKILL.md @@ -15,17 +15,17 @@ hooks: - matcher: "Bash" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/../careful/bin/check-careful.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/careful/bin/check-careful.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/careful/bin/check-careful.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking for destructive commands..." - matcher: "Edit" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking freeze boundary..." - matcher: "Write" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking freeze boundary..." --- diff --git a/guard/SKILL.md.tmpl b/guard/SKILL.md.tmpl index 5829dbe48f..c006b7f99b 100644 --- a/guard/SKILL.md.tmpl +++ b/guard/SKILL.md.tmpl @@ -20,17 +20,17 @@ hooks: - matcher: "Bash" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/../careful/bin/check-careful.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/careful/bin/check-careful.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/careful/bin/check-careful.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking for destructive commands..." - matcher: "Edit" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking freeze boundary..." - matcher: "Write" hooks: - type: command - command: "bash ${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking freeze boundary..." sensitive: true --- diff --git a/investigate/SKILL.md b/investigate/SKILL.md index 9431b58f6e..abfd6f280c 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -23,12 +23,12 @@ hooks: - matcher: "Edit" hooks: - type: command - command: 'bash -c ''S="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"; [ -x "$S" ] || S="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"; [ -x "$S" ] && bash "$S" || exit 0''' + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking debug scope boundary..." - matcher: "Write" hooks: - type: command - command: 'bash -c ''S="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"; [ -x "$S" ] || S="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"; [ -x "$S" ] && bash "$S" || exit 0''' + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking debug scope boundary..." gbrain: schema: 1 diff --git a/investigate/SKILL.md.tmpl b/investigate/SKILL.md.tmpl index 67e254d743..8cc51db1bf 100644 --- a/investigate/SKILL.md.tmpl +++ b/investigate/SKILL.md.tmpl @@ -30,12 +30,12 @@ hooks: - matcher: "Edit" hooks: - type: command - command: 'bash -c ''S="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"; [ -x "$S" ] || S="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"; [ -x "$S" ] && bash "$S" || exit 0''' + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking debug scope boundary..." - matcher: "Write" hooks: - type: command - command: 'bash -c ''S="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"; [ -x "$S" ] || S="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"; [ -x "$S" ] && bash "$S" || exit 0''' + command: 'bash -c ''D="$PWD"; while :; do S="$D/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; [ "$D" = "/" ] && break; D="$(dirname "$D")"; done; S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking debug scope boundary..." gbrain: schema: 1 diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 3554094ca5..ba8bd9af39 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1837,7 +1837,7 @@ describe('Codex generation (--host codex)', () => { expect(frontmatter).toContain('YC Office Hours'); }); - test('hook skills have safety prose and no hooks: in frontmatter', () => { + test('Codex hook skills have safety prose and no hooks: in frontmatter', () => { const HOOK_SKILLS = ['gstack-careful', 'gstack-freeze', 'gstack-guard']; for (const skillName of HOOK_SKILLS) { const content = fs.readFileSync(path.join(AGENTS_DIR, skillName, 'SKILL.md'), 'utf-8'); @@ -1850,6 +1850,59 @@ describe('Codex generation (--host codex)', () => { } }); + test('Claude hook commands do not depend on CLAUDE_SKILL_DIR', () => { + // #1871: Claude Code 2.1.162 does not populate CLAUDE_SKILL_DIR for + // skill-frontmatter PreToolUse hooks. If these commands use that variable, + // they expand to paths like /../freeze/bin/check-freeze.sh and fail before + // the safety hook can do its job. + const HOOK_SKILLS = ['careful', 'freeze', 'guard', 'investigate']; + for (const skillName of HOOK_SKILLS) { + const content = fs.readFileSync(path.join(ROOT, skillName, 'SKILL.md'), 'utf-8'); + const fmEnd = content.indexOf('\n---', 4); + const frontmatter = content.slice(4, fmEnd); + expect(frontmatter).toContain('hooks:'); + expect(frontmatter).not.toContain('CLAUDE_SKILL_DIR'); + expect(frontmatter).not.toContain('CLAUDE_PLUGIN_ROOT'); + expect(frontmatter).not.toContain('git rev-parse --show-toplevel'); + expect(frontmatter).toContain('while :; do'); + expect(frontmatter).toContain('$D/.claude/skills/gstack/'); + expect(frontmatter).toContain('$HOME/.claude/skills/gstack/'); + } + }); + + test('Claude hook commands resolve project-local install from nested non-git cwd', () => { + const { execFileSync } = require('child_process'); + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hook-local-')); + const projectRoot = path.join(tmpRoot, 'project'); + const nestedCwd = path.join(projectRoot, 'nested', 'plain-dir'); + const scriptPath = path.join(projectRoot, '.claude', 'skills', 'gstack', 'careful', 'bin', 'check-careful.sh'); + + fs.mkdirSync(path.dirname(scriptPath), { recursive: true }); + fs.mkdirSync(nestedCwd, { recursive: true }); + fs.writeFileSync(scriptPath, '#!/usr/bin/env bash\nprintf local-careful'); + fs.chmodSync(scriptPath, 0o755); + + try { + const content = fs.readFileSync(path.join(ROOT, 'careful', 'SKILL.md'), 'utf-8'); + const fmEnd = content.indexOf('\n---', 4); + const frontmatter = content.slice(4, fmEnd); + const commandLine = frontmatter.split('\n').find(line => line.trim().startsWith('command: ')); + expect(commandLine).toBeTruthy(); + let command = commandLine!.trim().slice('command: '.length); + expect(command.startsWith("'")).toBe(true); + command = command.slice(1, -1).replaceAll("''", "'"); + + const output = execFileSync('bash', ['-c', command], { + cwd: nestedCwd, + input: '{}', + encoding: 'utf8', + }); + expect(output).toBe('local-careful'); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + test('all Codex SKILL.md files have auto-generated header', () => { for (const skill of CODEX_SKILLS) { const content = fs.readFileSync(path.join(AGENTS_DIR, skill.codexName, 'SKILL.md'), 'utf-8');