This commit is contained in:
neallee2012 2026-07-15 08:11:06 +08:00 committed by GitHub
commit c6b6111146
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 473 additions and 45 deletions

View File

@ -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

View File

@ -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.

View File

@ -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

37
implementation-notes.html Normal file
View File

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cross-platform gstack enforcement hook</title>
</head>
<body>
<main>
<h1>Cross-platform gstack enforcement hook</h1>
<h2>Design decisions</h2>
<ul>
<li>Required mode generates <code>.claude/hooks/check-gstack.cjs</code>. The explicit CommonJS extension is unaffected by a consumer repository's <code>package.json</code> module type.</li>
<li>The one registered command launches Node 18+ and resolves <code>CLAUDE_PROJECT_DIR</code> inside JavaScript through <code>process.env</code>. It contains no Bash, PowerShell, or cmd environment expansion.</li>
<li>The launcher validates <code>CLAUDE_PROJECT_DIR</code> before calling <code>path.join</code>. Missing or empty project context emits a structured <code>PreToolUse</code> denial, writes a clear reason to stderr, and exits zero instead of surfacing a hook execution error.</li>
<li>Path resolution and module loading run inside the same <code>try/catch</code>. A nonexistent project, an existing project without the generated hook, or any other load failure returns the same concise denial without exposing a stack trace or the full project path.</li>
<li>The generated hook resolves the user home in <code>HOME</code>, <code>USERPROFILE</code>, then <code>os.homedir()</code> order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.</li>
<li>The hook contract follows <code>docs/spikes/claude-code-hook-mutation.md</code>: pass-through writes <code>{}</code>; denial writes a <code>hookSpecificOutput</code> object for <code>PreToolUse</code> with <code>permissionDecision: "deny"</code>.</li>
<li>Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and <code>permissionDecisionReason</code>, so the runner does not report a hook execution error.</li>
<li>Rerunning required mode replaces a matching legacy <code>check-gstack.sh</code> registration, removes duplicate matching registrations, and deletes the obsolete Bash hook only after the CommonJS hook is generated and registered successfully. If Git tracks the deleted hook, the printed <code>git add</code> command includes its path so the suggested migration commit stages the deletion; untracked removed hooks are omitted because they have no index change. Unrelated project hooks remain unchanged.</li>
</ul>
<h2>Deviations</h2>
<p>None. The implementation uses the requested Node launcher, one hook handler, and the repository's documented hook output schema.</p>
<h2>Tradeoffs</h2>
<ul>
<li>The generated project now requires Node 18+ at hook execution time. This removes shell selection ambiguity and matches the requested runtime floor.</li>
<li>The generated hook is invoked through Node rather than as an executable file, so POSIX executable mode bits are no longer part of the contract.</li>
</ul>
<h2>Open questions</h2>
<p>None.</p>
</main>
</body>
</html>

View File

@ -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<string, string> } =
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<string, string> } =
}
}
function runHook(
command: string,
opts: { cwd: string; env: Record<string, string> },
): { 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<string, string> },
): { 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,