diff --git a/.claude/dispatch/inbox.md b/.claude/dispatch/inbox.md new file mode 100644 index 000000000..41069cfa8 --- /dev/null +++ b/.claude/dispatch/inbox.md @@ -0,0 +1,3 @@ +# Dispatch inbox + +- [ ] Investigate Windows-safe suite filtering and prerequisites for `test/readme-throughput.test.ts` and `browse/test/tab-isolation.test.ts`; `origin/main` reproduced the failures. (files: package.json, test/readme-throughput.test.ts, browse/test/tab-isolation.test.ts) — noticed 2026-07-10 diff --git a/README.md b/README.md index 4bb177c3a..8f191f445 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep (cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work" ``` -No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). +No vendored gstack install in your repo, no version drift, no manual upgrades. Required mode commits one Node 18+ CommonJS enforcement hook, so the same project hook works from POSIX and Windows hook runners without duplicate shell-specific handlers. If the hook runner omits its project directory or provides a stale path, enforcement fails closed with an intentional denial instead of a hook error. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Swap `required` for `optional` if you'd rather nudge teammates than block them. diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 256735f8b..bf5a07428 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -65,12 +65,13 @@ cd ~/.claude/skills/gstack && ./setup --team Skills like /qa, /ship, /review, /investigate, and /browse become available after install. Use /browse for all web browsing. Use ~/.claude/skills/gstack/... for gstack file paths.' else - SNIPPET='## gstack (REQUIRED — global install) + SNIPPET=$(cat <<'SNIPPET_EOF' +## gstack (REQUIRED — global install) **Before doing ANY work, verify gstack is installed:** -```bash -test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING" +``` +node -e "const fs=require('node:fs'),os=require('node:os'),p=require('node:path'); console.log(fs.existsSync(p.join(os.homedir(),'.claude','skills','gstack','bin')) ? 'GSTACK_OK' : 'GSTACK_MISSING')" ``` If GSTACK_MISSING: STOP. Do not proceed. Tell the user: @@ -87,7 +88,9 @@ Do not skip skills, ignore gstack errors, or work around missing gstack. Using gstack skills: After install, skills like /qa, /ship, /review, /investigate, and /browse are available. Use /browse for all web browsing. -Use ~/.claude/skills/gstack/... for gstack file paths (the global path).' +Use ~/.claude/skills/gstack/... for gstack file paths (the global path). +SNIPPET_EOF +) fi # Check if CLAUDE.md already has a gstack section @@ -109,33 +112,60 @@ if [ "$MODE" = "required" ]; then HOOKS_DIR="$REPO_ROOT/.claude/hooks" SETTINGS="$REPO_ROOT/.claude/settings.json" - # Create enforcement hook script + # Create a CommonJS hook so consumer package.json module settings cannot change + # how Node 18+ loads it. mkdir -p "$HOOKS_DIR" - cat > "$HOOKS_DIR/check-gstack.sh" << 'HOOK_EOF' -#!/bin/bash -# Block skill usage when gstack is not installed globally. + cat > "$HOOKS_DIR/check-gstack.cjs" << 'HOOK_EOF' +'use strict'; -if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then - cat >&2 <<'MSG' -BLOCKED: gstack is not installed globally. +// Block skill usage when gstack is not installed globally. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); -gstack is required for AI-assisted work in this repo. +const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir(); +const gstackBin = path.join(homeDir, '.claude', 'skills', 'gstack', 'bin'); +let verificationError = null; +let installed = false; + +try { + installed = fs.statSync(gstackBin).isDirectory(); +} catch (error) { + if (error && error.code !== 'ENOENT') { + verificationError = error; + } +} + +if (installed) { + process.stdout.write('{}\n'); +} else { + const projectDir = process.env.CLAUDE_PROJECT_DIR || '(unknown project)'; + const heading = verificationError + ? 'BLOCKED: the global gstack install could not be verified.' + : 'BLOCKED: gstack is not installed globally.'; + const instructions = `${heading} + +gstack is required for AI-assisted work in ${projectDir}. Install it: git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack cd ~/.claude/skills/gstack && ./setup --team Then restart your AI coding tool. -MSG - echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}' - exit 0 -fi +`; -echo '{}' + process.stderr.write(instructions); + process.stdout.write(`${JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: instructions, + }, + })}\n`); +} HOOK_EOF - chmod +x "$HOOKS_DIR/check-gstack.sh" - GENERATED+=(".claude/hooks/check-gstack.sh") - echo " + .claude/hooks/check-gstack.sh — enforcement hook" + GENERATED+=(".claude/hooks/check-gstack.cjs") + echo " + .claude/hooks/check-gstack.cjs — cross-platform enforcement hook" # Add hook to project-level settings.json if command -v bun >/dev/null 2>&1; then @@ -149,18 +179,32 @@ HOOK_EOF if (!settings.hooks) settings.hooks = {}; if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = []; - // Dedup - const exists = settings.hooks.PreToolUse.some(entry => - entry.matcher === 'Skill' && - entry.hooks && entry.hooks.some(h => h.command && h.command.includes('check-gstack')) - ); + const hookCommand = \"node -e \\\"const deny=reason=>{console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))};const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){deny('BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.')}else{try{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}catch{deny('BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.')}}\\\"\"; + let found = false; + settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => { + if (entry.matcher !== 'Skill' || !entry.hooks) return true; - if (!exists) { + const matchingHooks = entry.hooks.filter( + h => h.command && h.command.includes('check-gstack') + ); + if (matchingHooks.length === 0) return true; + + entry.hooks = entry.hooks.filter( + h => !h.command || !h.command.includes('check-gstack') + ); + if (!found) { + entry.hooks.push({ type: 'command', command: hookCommand }); + found = true; + } + return entry.hooks.length > 0; + }); + + if (!found) { settings.hooks.PreToolUse.push({ matcher: 'Skill', hooks: [{ type: 'command', - command: '\"\$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"' + command: hookCommand }] }); } @@ -171,6 +215,16 @@ HOOK_EOF " 2>/dev/null GENERATED+=(".claude/settings.json") echo " + .claude/settings.json — PreToolUse hook registered" + + # Remove the obsolete Bash hook only after its replacement is generated and + # registered successfully. Other project hooks are left untouched. + if [ -e "$HOOKS_DIR/check-gstack.sh" ] || [ -L "$HOOKS_DIR/check-gstack.sh" ]; then + rm "$HOOKS_DIR/check-gstack.sh" + if (cd "$REPO_ROOT" && git ls-files --error-unmatch -- .claude/hooks/check-gstack.sh >/dev/null 2>&1); then + GENERATED+=(".claude/hooks/check-gstack.sh") + fi + echo " - .claude/hooks/check-gstack.sh — removed legacy enforcement hook" + fi else echo " ! bun not found — manually add the PreToolUse hook to .claude/settings.json" fi diff --git a/implementation-notes.html b/implementation-notes.html new file mode 100644 index 000000000..c740af189 --- /dev/null +++ b/implementation-notes.html @@ -0,0 +1,37 @@ + + + + + + Cross-platform gstack enforcement hook + + +
+

Cross-platform gstack enforcement hook

+ +

Design decisions

+ + +

Deviations

+

None. The implementation uses the requested Node launcher, one hook handler, and the repository's documented hook output schema.

+ +

Tradeoffs

+ + +

Open questions

+

None.

+
+ + diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index ce8c1d610..17c815160 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -2,12 +2,21 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; +import { execSync, spawnSync } from 'child_process'; const ROOT = path.resolve(import.meta.dir, '..'); -const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook'); -const SESSION_UPDATE = path.join(ROOT, 'bin', 'gstack-session-update'); -const TEAM_INIT = path.join(ROOT, 'bin', 'gstack-team-init'); + +function bashCommand(filePath: string): string { + return `bash "${filePath.replace(/\\/g, '/')}"`; +} + +const SETTINGS_HOOK = bashCommand(path.join(ROOT, 'bin', 'gstack-settings-hook')); +const SESSION_UPDATE = bashCommand(path.join(ROOT, 'bin', 'gstack-session-update')); +const TEAM_INIT = bashCommand(path.join(ROOT, 'bin', 'gstack-team-init')); +const MISSING_PROJECT_REASON = + 'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.'; +const HOOK_LOAD_REASON = + 'BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.'; function mkTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-test-')); @@ -17,7 +26,11 @@ function run(cmd: string, opts: { cwd?: string; env?: Record } = try { const stdout = execSync(cmd, { cwd: opts.cwd, - env: { ...process.env, ...opts.env }, + env: { + ...process.env, + ...(process.platform === 'win32' ? { MSYS_NO_PATHCONV: '1' } : {}), + ...opts.env, + }, encoding: 'utf-8', timeout: 10000, }); @@ -27,6 +40,63 @@ function run(cmd: string, opts: { cwd?: string; env?: Record } = } } +function runHook( + command: string, + opts: { cwd: string; env: Record }, +): { stdout: string; stderr: string; exitCode: number } { + const result = spawnSync(command, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + encoding: 'utf-8', + shell: true, + timeout: 10000, + }); + + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +function runHookInPowerShell( + command: string, + opts: { cwd: string; env: Record }, +): { stdout: string; stderr: string; exitCode: number } { + const result = spawnSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', command], + { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + encoding: 'utf-8', + timeout: 10000, + }, + ); + + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +function expectStructuredDeny( + result: { stdout: string; stderr: string; exitCode: number }, + reason: string, +): void { + expect(result.exitCode).toBe(0); + expect(result.stderr.trim()).toBe(reason); + expect(result.stderr).not.toContain('MODULE_NOT_FOUND'); + expect(JSON.parse(result.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason, + }, + }); +} + describe('gstack-settings-hook', () => { let tmpDir: string; let settingsFile: string; @@ -227,29 +297,290 @@ describe('gstack-team-init', () => { const claude = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); expect(claude).toContain('## gstack (REQUIRED'); expect(claude).toContain('GSTACK_MISSING'); + expect(claude).toContain("require('node:os')"); + expect(claude).not.toContain('test -d ~/.claude/skills/gstack/bin'); }); test('required: creates enforcement hook', () => { - run(`${TEAM_INIT} required`, { cwd: tmpDir }); - const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.sh'); + const result = run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.cjs'); expect(fs.existsSync(hookPath)).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.claude', 'hooks', 'check-gstack.sh')), + ).toBe(false); const hook = fs.readFileSync(hookPath, 'utf-8'); + expect(hook).toContain("'use strict'"); + expect(hook).toContain("require('node:fs')"); + expect(hook).toContain( + 'process.env.HOME || process.env.USERPROFILE || os.homedir()', + ); + expect(hook).toContain('process.env.CLAUDE_PROJECT_DIR'); + expect(hook).toContain("hookEventName: 'PreToolUse'"); + expect(hook).toContain("permissionDecision: 'deny'"); expect(hook).toContain('BLOCKED: gstack is not installed'); - // Should be executable - const stat = fs.statSync(hookPath); - expect(stat.mode & 0o111).toBeGreaterThan(0); + expect(hook).not.toContain('#!/bin/bash'); + expect(result.stdout).not.toMatch(/git add .*check-gstack\.sh/); }); - test('required: creates project settings.json with PreToolUse hook', () => { + test('required: registers one shell-neutral project hook', () => { run(`${TEAM_INIT} required`, { cwd: tmpDir }); const settingsPath = path.join(tmpDir, '.claude', 'settings.json'); expect(fs.existsSync(settingsPath)).toBe(true); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); expect(settings.hooks.PreToolUse).toHaveLength(1); - expect(settings.hooks.PreToolUse[0].matcher).toBe('Skill'); - expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain('check-gstack'); + const entry = settings.hooks.PreToolUse[0]; + expect(entry.matcher).toBe('Skill'); + expect(entry.hooks).toHaveLength(1); + expect(entry.hooks[0]).not.toHaveProperty('shell'); + expect(entry.hooks[0].command).toBe( + `node -e "const deny=reason=>{console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))};const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){deny('BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.')}else{try{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}catch{deny('BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.')}}"`, + ); + expect(entry.hooks[0].command).not.toMatch( + /\$CLAUDE_PROJECT_DIR|\$env:CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/, + ); + expect(entry.hooks[0].command).not.toContain("permissionDecision:'allow'"); }); + test('required: hook allows with valid empty JSON when gstack is installed', () => { + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + const command = settings.hooks.PreToolUse[0].hooks[0].command; + const fakeHome = path.join(tmpDir, 'home'); + const nestedCwd = path.join(tmpDir, 'nested', 'working', 'directory'); + fs.mkdirSync(path.join(fakeHome, '.claude', 'skills', 'gstack', 'bin'), { + recursive: true, + }); + fs.mkdirSync(nestedCwd, { recursive: true }); + + const result = runHook(command, { + cwd: nestedCwd, + env: { + CLAUDE_PROJECT_DIR: tmpDir, + HOME: fakeHome, + USERPROFILE: path.join(tmpDir, 'unused-userprofile'), + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({}); + }); + + test('required: missing project env denies through the platform default shell', () => { + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + const command = settings.hooks.PreToolUse[0].hooks[0].command; + + const result = runHook(command, { + cwd: tmpDir, + env: { CLAUDE_PROJECT_DIR: '' }, + }); + + expectStructuredDeny(result, MISSING_PROJECT_REASON); + }); + + test('required: missing project env denies through PowerShell', () => { + if (process.platform !== 'win32') return; + + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + const command = settings.hooks.PreToolUse[0].hooks[0].command; + + const result = runHookInPowerShell(command, { + cwd: tmpDir, + env: { CLAUDE_PROJECT_DIR: '' }, + }); + + expectStructuredDeny(result, MISSING_PROJECT_REASON); + }, 30_000); + + test('required: stale project paths deny through the platform default shell', () => { + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + const command = settings.hooks.PreToolUse[0].hooks[0].command; + const existingWithoutHook = path.join(tmpDir, 'existing-without-hook'); + fs.mkdirSync(existingWithoutHook); + + for (const projectDir of [ + path.join(tmpDir, 'nonexistent-project'), + existingWithoutHook, + ]) { + const result = runHook(command, { + cwd: tmpDir, + env: { CLAUDE_PROJECT_DIR: projectDir }, + }); + + expectStructuredDeny(result, HOOK_LOAD_REASON); + expect(result.stderr).not.toContain(projectDir); + } + }); + + test('required: stale project paths deny through PowerShell', () => { + if (process.platform !== 'win32') return; + + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + const command = settings.hooks.PreToolUse[0].hooks[0].command; + const existingWithoutHook = path.join(tmpDir, 'powershell-without-hook'); + fs.mkdirSync(existingWithoutHook); + + for (const projectDir of [ + path.join(tmpDir, 'powershell-nonexistent'), + existingWithoutHook, + ]) { + const result = runHookInPowerShell(command, { + cwd: tmpDir, + env: { CLAUDE_PROJECT_DIR: projectDir }, + }); + + expectStructuredDeny(result, HOOK_LOAD_REASON); + expect(result.stderr).not.toContain(projectDir); + } + }, 30_000); + + test('required: hook runs in PowerShell with USERPROFILE home fallback', () => { + if (process.platform !== 'win32') return; + + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + const command = settings.hooks.PreToolUse[0].hooks[0].command; + const fakeHome = path.join(tmpDir, 'powershell-home'); + fs.mkdirSync(path.join(fakeHome, '.claude', 'skills', 'gstack', 'bin'), { + recursive: true, + }); + + const result = runHookInPowerShell(command, { + cwd: tmpDir, + env: { + CLAUDE_PROJECT_DIR: tmpDir, + HOME: '', + USERPROFILE: fakeHome, + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({}); + }, 30_000); + + test('required: missing gstack returns an intentional deny, not a hook error', () => { + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + const command = settings.hooks.PreToolUse[0].hooks[0].command; + const fakeHome = path.join(tmpDir, 'home-without-gstack'); + fs.mkdirSync(fakeHome, { recursive: true }); + + const result = runHook(command, { + cwd: tmpDir, + env: { + CLAUDE_PROJECT_DIR: tmpDir, + HOME: fakeHome, + USERPROFILE: fakeHome, + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('BLOCKED: gstack is not installed globally.'); + expect(result.stderr).toContain('git clone --depth 1'); + const decision = JSON.parse(result.stdout); + expect(decision).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: expect.stringContaining( + 'Then restart your AI coding tool.', + ), + }, + }); + }); + + test('required: rerun removes a legacy Bash hook without touching other hooks', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + const legacyHook = path.join(hooksDir, 'check-gstack.sh'); + const unrelatedHook = path.join(hooksDir, 'check-project.cjs'); + const unrelatedHookContents = "console.log('project hook');\n"; + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(legacyHook, '#!/bin/bash\n'); + fs.writeFileSync(unrelatedHook, unrelatedHookContents); + fs.writeFileSync( + path.join(tmpDir, '.claude', 'settings.json'), + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: 'Skill', + hooks: [{ + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh"', + }], + }, + ], + }, + }), + ); + execSync('git add .claude', { cwd: tmpDir }); + execSync('git commit -m "add legacy hook"', { cwd: tmpDir }); + + const migration = run(`${TEAM_INIT} required`, { cwd: tmpDir }); + expect(fs.existsSync(legacyHook)).toBe(false); + expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); + const suggestedGitAdd = migration.stdout + .split('\n') + .find(line => line.startsWith(' git add ')) + ?.trim(); + if (!suggestedGitAdd) { + throw new Error('gstack-team-init did not print a git add command'); + } + expect(suggestedGitAdd.split(/\s+/)).toContain( + '.claude/hooks/check-gstack.sh', + ); + execSync(suggestedGitAdd, { cwd: tmpDir }); + execSync('git commit -m "migrate required hook"', { cwd: tmpDir }); + expect( + execSync('git status --short', { cwd: tmpDir, encoding: 'utf-8' }), + ).toBe(''); + + const rerun = run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + expect(fs.existsSync(legacyHook)).toBe(false); + expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); + const rerunGitAdd = rerun.stdout + .split('\n') + .find(line => line.startsWith(' git add ')) + ?.trim(); + if (!rerunGitAdd) { + throw new Error('gstack-team-init rerun did not print a git add command'); + } + expect(rerunGitAdd.split(/\s+/)).not.toContain( + '.claude/hooks/check-gstack.sh', + ); + execSync(rerunGitAdd, { cwd: tmpDir }); + expect( + execSync('git status --short', { cwd: tmpDir, encoding: 'utf-8' }), + ).toBe(''); + expect(settings.hooks.PreToolUse).toHaveLength(1); + expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1); + expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain( + 'check-gstack.cjs', + ); + }, 30_000); + test('idempotent: running twice does not duplicate CLAUDE.md section', () => { run(`${TEAM_INIT} optional`, { cwd: tmpDir }); run(`${TEAM_INIT} optional`, { cwd: tmpDir }); @@ -291,7 +622,11 @@ describe('gstack-team-init', () => { fs.mkdirSync(skillsDir, { recursive: true }); const targetDir = mkTmpDir(); fs.writeFileSync(path.join(targetDir, 'VERSION'), '0.14.0.0'); - fs.symlinkSync(targetDir, path.join(skillsDir, 'gstack')); + fs.symlinkSync( + targetDir, + path.join(skillsDir, 'gstack'), + process.platform === 'win32' ? 'junction' : 'dir', + ); const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir }); expect(result.exitCode).toBe(0); @@ -329,7 +664,7 @@ describe('setup --team / --no-team / -q', () => { test( 'setup -q produces no stdout', () => { - const result = run(`${path.join(ROOT, 'setup')} -q`, { cwd: ROOT }); + const result = run(`${bashCommand(path.join(ROOT, 'setup'))} -q`, { cwd: ROOT }); // -q should suppress informational output (may still have some output from build) // The key test is that the "Skill naming:" prompt and "gstack ready" messages are suppressed expect(result.stdout).not.toContain('Skill naming:'); @@ -341,8 +676,7 @@ describe('setup --team / --no-team / -q', () => { test( 'setup --local prints deprecation warning', () => { - // stderr capture: run via bash redirect so we can capture stderr - const result = run(`bash -c '${path.join(ROOT, 'setup')} --local -q 2>&1'`, { cwd: ROOT }); + const result = run(`${bashCommand(path.join(ROOT, 'setup'))} --local -q 2>&1`, { cwd: ROOT }); expect(result.stdout).toContain('deprecated'); }, 180_000,