mirror of https://github.com/garrytan/gstack.git
253 lines
9.4 KiB
Bash
Executable File
253 lines
9.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-team-init — generate repo-level bootstrap files for team mode
|
|
#
|
|
# Usage:
|
|
# gstack-team-init optional # gentle CLAUDE.md suggestion, one-time offer
|
|
# gstack-team-init required # CLAUDE.md enforcement + PreToolUse hook
|
|
#
|
|
# Run from the root of your team's repo (not from the gstack directory).
|
|
|
|
set -euo pipefail
|
|
|
|
MODE="${1:-}"
|
|
|
|
if [ "$MODE" != "optional" ] && [ "$MODE" != "required" ]; then
|
|
echo "Usage: gstack-team-init {optional|required}" >&2
|
|
echo "" >&2
|
|
echo " optional — suggest gstack install once per developer (gentle)" >&2
|
|
echo " required — enforce gstack install, block work without it" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Must be in a git repo
|
|
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
echo "Error: not in a git repository. Run from your project root." >&2
|
|
exit 1
|
|
fi
|
|
|
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
CLAUDE_MD="$REPO_ROOT/CLAUDE.md"
|
|
GENERATED=()
|
|
|
|
# ── Migrate vendored copy if present ──────────────────────────
|
|
|
|
if [ -d "$REPO_ROOT/.claude/skills/gstack" ] && [ ! -L "$REPO_ROOT/.claude/skills/gstack" ]; then
|
|
if [ -f "$REPO_ROOT/.claude/skills/gstack/VERSION" ] || [ -d "$REPO_ROOT/.claude/skills/gstack/.git" ]; then
|
|
echo " Found vendored gstack copy at $REPO_ROOT/.claude/skills/gstack"
|
|
echo " Team mode uses the global install — removing vendored copy..."
|
|
( cd "$REPO_ROOT" && git rm -r --cached .claude/skills/gstack/ 2>/dev/null ) || true
|
|
if [ -f "$REPO_ROOT/.gitignore" ]; then
|
|
if ! grep -qF '.claude/skills/gstack/' "$REPO_ROOT/.gitignore" 2>/dev/null; then
|
|
echo '.claude/skills/gstack/' >> "$REPO_ROOT/.gitignore"
|
|
fi
|
|
else
|
|
echo '.claude/skills/gstack/' > "$REPO_ROOT/.gitignore"
|
|
fi
|
|
rm -rf "$REPO_ROOT/.claude/skills/gstack"
|
|
GENERATED+=(".gitignore")
|
|
echo " Removed vendored copy and added .claude/skills/gstack/ to .gitignore"
|
|
fi
|
|
fi
|
|
|
|
# ── CLAUDE.md snippet ──────────────────────────────────────────
|
|
|
|
if [ "$MODE" = "optional" ]; then
|
|
SNIPPET='## gstack (recommended)
|
|
|
|
This project uses [gstack](https://github.com/garrytan/gstack) for AI-assisted workflows.
|
|
Install it for the best experience:
|
|
|
|
```bash
|
|
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
|
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=$(cat <<'SNIPPET_EOF'
|
|
## gstack (REQUIRED — global install)
|
|
|
|
**Before doing ANY work, verify gstack is installed:**
|
|
|
|
```
|
|
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:
|
|
|
|
> gstack is required for all AI-assisted work in this repo.
|
|
> Install it:
|
|
> ```bash
|
|
> 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.
|
|
|
|
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).
|
|
SNIPPET_EOF
|
|
)
|
|
fi
|
|
|
|
# Check if CLAUDE.md already has a gstack section
|
|
if [ -f "$CLAUDE_MD" ] && grep -q "## gstack" "$CLAUDE_MD" 2>/dev/null; then
|
|
echo "CLAUDE.md already has a gstack section. Skipping CLAUDE.md update."
|
|
echo " To replace it, remove the existing ## gstack section and re-run."
|
|
else
|
|
if [ -f "$CLAUDE_MD" ]; then
|
|
echo "" >> "$CLAUDE_MD"
|
|
fi
|
|
echo "$SNIPPET" >> "$CLAUDE_MD"
|
|
GENERATED+=("CLAUDE.md")
|
|
echo " + CLAUDE.md — added gstack $MODE section"
|
|
fi
|
|
|
|
# ── Required mode: enforcement hook ────────────────────────────
|
|
|
|
if [ "$MODE" = "required" ]; then
|
|
HOOKS_DIR="$REPO_ROOT/.claude/hooks"
|
|
SETTINGS="$REPO_ROOT/.claude/settings.json"
|
|
|
|
# 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.cjs" << 'HOOK_EOF'
|
|
'use strict';
|
|
|
|
// Block skill usage when gstack is not installed globally.
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
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.
|
|
`;
|
|
|
|
process.stderr.write(instructions);
|
|
const decision = {
|
|
permissionDecision: 'deny',
|
|
permissionDecisionReason: instructions,
|
|
};
|
|
process.stdout.write(`${JSON.stringify({
|
|
...decision,
|
|
hookSpecificOutput: {
|
|
hookEventName: 'PreToolUse',
|
|
...decision,
|
|
},
|
|
})}\n`);
|
|
}
|
|
HOOK_EOF
|
|
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
|
|
GSTACK_SETTINGS_PATH="$SETTINGS" bun -e "
|
|
const fs = require('fs');
|
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
|
|
|
let settings = {};
|
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
|
|
if (!settings.hooks) settings.hooks = {};
|
|
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
|
|
const hookMatcher = 'Skill|skill';
|
|
const hookCommand = \"node -e \\\"const deny=reason=>{const decision={permissionDecision:'deny',permissionDecisionReason:reason};console.error(reason);console.log(JSON.stringify({...decision,hookSpecificOutput:{hookEventName:'PreToolUse',...decision}}))};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 (!['Skill', hookMatcher].includes(entry.matcher) || !entry.hooks) return true;
|
|
|
|
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.matcher = hookMatcher;
|
|
entry.hooks.push({ type: 'command', command: hookCommand });
|
|
found = true;
|
|
}
|
|
return entry.hooks.length > 0;
|
|
});
|
|
|
|
if (!found) {
|
|
settings.hooks.PreToolUse.push({
|
|
matcher: hookMatcher,
|
|
hooks: [{
|
|
type: 'command',
|
|
command: hookCommand
|
|
}]
|
|
});
|
|
}
|
|
|
|
const tmp = settingsPath + '.tmp';
|
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
fs.renameSync(tmp, settingsPath);
|
|
" 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
|
|
fi
|
|
|
|
# ── Summary ────────────────────────────────────────────────────
|
|
|
|
echo ""
|
|
echo "Team mode ($MODE) initialized."
|
|
echo ""
|
|
if [ ${#GENERATED[@]} -gt 0 ]; then
|
|
echo "Commit the generated files:"
|
|
echo " git add ${GENERATED[*]}"
|
|
echo " git commit -m \"chore: require gstack for AI-assisted work\""
|
|
fi
|
|
echo ""
|
|
echo "Each developer then runs:"
|
|
echo " git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack"
|
|
echo " cd ~/.claude/skills/gstack && ./setup --team"
|