diff --git a/.claude/dispatch/inbox.md b/.claude/dispatch/inbox.md new file mode 100644 index 000000000..c2d5e4f56 --- /dev/null +++ b/.claude/dispatch/inbox.md @@ -0,0 +1,3 @@ +# Dispatch inbox + +- Investigate the curated Windows-safe suite prerequisites and filtering. `bun run test:windows` currently includes `test/readme-throughput.test.ts`, whose four script subprocess tests return status `-1` on this Windows environment, and `browse/test/tab-isolation.test.ts`, which aborts when the generated `browse/src/server-node.mjs` bundle is absent. Receipt: reproduced identically on detached `origin/main` SHA `7c9df1c568a9ea745508f679a329332b2c338063` after `bun install --frozen-lockfile`, using `bun run test:windows` with Git Bash prepended to `PATH`; result was 27 pass, 5 fail, 1 error, exit 1. Full base log: `C:\Users\kunle\.copilot\session-state\8631d6e3-fb62-412d-8bdc-dc6a8f3a46be\files\main-test-windows.log`. The temporary worktree was removed afterward. This is outside the cross-platform team-hook fix; `test/team-mode.test.ts` passes in full. diff --git a/README.md b/README.md index 4bb177c3a..8233848d6 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, 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..74cd5af5d 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 projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){const reason='BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))}else{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}\\\"\"; + 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 }] }); } diff --git a/implementation-notes.html b/implementation-notes.html new file mode 100644 index 000000000..1de10a6d7 --- /dev/null +++ b/implementation-notes.html @@ -0,0 +1,36 @@ + + + + + + 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..a17fb6938 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -2,12 +2,17 @@ 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')); function mkTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-test-')); @@ -17,7 +22,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 +36,47 @@ 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, + }; +} + describe('gstack-settings-hook', () => { let tmpDir: string; let settingsFile: string; @@ -227,27 +277,216 @@ 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 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'); }); - 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 projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){const reason='BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))}else{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}"`, + ); + 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: '' }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('BLOCKED: CLAUDE_PROJECT_DIR is unavailable'); + expect(JSON.parse(result.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + 'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.', + }, + }); + }); + + 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: '' }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('BLOCKED: CLAUDE_PROJECT_DIR is unavailable'); + expect(JSON.parse(result.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + 'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.', + }, + }); + }); + + 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({}); + }); + + 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 upgrades a legacy Bash hook without duplicates', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'check-gstack.sh'), '#!/bin/bash\n'); + 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"', + }], + }, + ], + }, + }), + ); + + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + 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', + ); }); test('idempotent: running twice does not duplicate CLAUDE.md section', () => { @@ -291,7 +530,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 +572,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 +584,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,