Startup script for claude on the web sessions with uv, prek, and a bunch of skills. Ensures skills are available after startup and compaction.
Copy _setup.sh to the cloud environment configuration.
| #!/bin/bash | |
| set -euo pipefail | |
| export HOME="${CLAUDE_HOME:-/root}" | |
| # Ensure uv is available | |
| if ! command -v uv &>/dev/null; then | |
| curl -LsSf https://astral.sh/uv/install.sh | sh | |
| fi | |
| export PATH="$HOME/.local/bin/uv/tools/bin:$PATH" | |
| export PATH="$HOME/.local/share/uv/tools/bin:$PATH" | |
| export PATH="$(uv tool bin):$PATH" | |
| # Sync venv if a pyproject.toml exists | |
| if [ -f "pyproject.toml" ]; then | |
| uv sync | |
| fi | |
| # Ensure prek is installed as a uv tool | |
| if ! command -v prek &>/dev/null; then | |
| uv tool install prek | |
| fi | |
| # Install git hooks if a pre-commit config exists | |
| if [ -f ".pre-commit-config.yaml" ]; then | |
| prek install | |
| fi | |
| # Ensure jq is available | |
| if ! command -v jq &>/dev/null; then | |
| apt-get update && apt-get install -y jq | |
| fi | |
| # Ensure code-review-graph is installed as a uv tool | |
| if ! command -v code-review-graph &>/dev/null; then | |
| uv tool install code-review-graph | |
| fi | |
| if command -v code-review-graph &>/dev/null; then | |
| code-review-graph install --platform claude-code | |
| fi | |
| npx --yes skills \ | |
| add ahgraber/skills \ | |
| --yes \ | |
| --agent claude-code \ | |
| --skill pr-review commit \ | |
| api-design \ | |
| code-review \ | |
| commit-message \ | |
| handoff \ | |
| mcp-research \ | |
| python \ | |
| python-concurrency-performance \ | |
| python-data-state \ | |
| python-design-modularity \ | |
| python-errors-reliability \ | |
| python-integrations-resilience \ | |
| python-notebooks-async \ | |
| python-runtime-operations \ | |
| python-testing \ | |
| python-types-contracts \ | |
| python-workflow-delivery \ | |
| receiving-feedback \ | |
| sdd \ | |
| sdd-apply \ | |
| sdd-archive \ | |
| sdd-derive \ | |
| sdd-explore \ | |
| sdd-propose \ | |
| sdd-sync \ | |
| sdd-translate \ | |
| sdd-verify \ | |
| shell-scripts | |
| # make available to claude harness (root) | |
| mkdir -p /root/.claude/skills | |
| ln -sfn /home/user/.claude/skills/* /root/.claude/skills/ | |
| # Install using-skills SessionStart hooks | |
| tmpdir=$(mktemp -d) | |
| git clone --depth=1 https://gist.github.com/ahgraber/f24792b25edeab8e029d621bbed54f49.git "$tmpdir/gist" | |
| bash "$tmpdir/gist/install.sh" | |
| rm -rf "$tmpdir" |
| #!/usr/bin/env bash | |
| # Install the using-skills SessionStart hooks into a user's ~/.claude/. | |
| # | |
| # Usage: | |
| # curl -sSL https://gist.githubusercontent.com/USER/HASH/raw/install.sh | bash | |
| # or: | |
| # GIST_URL=https://gist.github.com/OTHER/HASH.git ./install.sh | |
| # | |
| # Idempotent: reruns refresh the hook files and replace any previously | |
| # installed SessionStart entries for these commands (without touching | |
| # other SessionStart/Stop/etc. hooks or permissions). | |
| set -euo pipefail | |
| GIST_URL="${GIST_URL:-https://gist.github.com/ahgraber/f24792b25edeab8e029d621bbed54f49.git}" | |
| HOOKS_DIR="${HOOKS_DIR:-$HOME/.claude/hooks}" | |
| SETTINGS="${SETTINGS:-$HOME/.claude/settings.json}" | |
| FILES=(using-skills.sh using-skills-instr.md reload-skills-catalog.py) | |
| EXECUTABLES=(using-skills.sh reload-skills-catalog.py) | |
| # Commands this installer owns. Existing SessionStart entries that reference | |
| # any of these are replaced on rerun (so edits to the gist propagate cleanly). | |
| MANAGED_CMDS_JSON='["~/.claude/hooks/using-skills.sh","uv run ~/.claude/hooks/reload-skills-catalog.py"]' | |
| NEW_ENTRIES_JSON='[ | |
| { | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "~/.claude/hooks/using-skills.sh", | |
| "statusMessage": "Loading skill-usage instructions" | |
| } | |
| ] | |
| }, | |
| { | |
| "matcher": "compact", | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "uv run ~/.claude/hooks/reload-skills-catalog.py", | |
| "statusMessage": "Reloading skills catalog" | |
| }, | |
| { | |
| "type": "command", | |
| "command": "~/.claude/hooks/using-skills.sh", | |
| "statusMessage": "Reloading skill-usage instructions" | |
| } | |
| ] | |
| } | |
| ]' | |
| # --- prereq check --- | |
| for cmd in git jq; do | |
| command -v "$cmd" >/dev/null 2>&1 || { | |
| echo "error: '$cmd' is required on PATH" >&2 | |
| exit 1 | |
| } | |
| done | |
| # --- stage files from gist --- | |
| tmpdir=$(mktemp -d) | |
| trap 'rm -rf "$tmpdir"' EXIT | |
| echo "==> Cloning $GIST_URL" | |
| git clone --depth=1 "$GIST_URL" "$tmpdir/gist" | |
| for f in "${FILES[@]}"; do | |
| [[ -f "$tmpdir/gist/$f" ]] || { | |
| echo "error: gist is missing '$f'" >&2 | |
| exit 1 | |
| } | |
| done | |
| # --- install --- | |
| mkdir -p "$HOOKS_DIR" | |
| for f in "${FILES[@]}"; do | |
| install -m 0644 "$tmpdir/gist/$f" "$HOOKS_DIR/$f" | |
| done | |
| for f in "${EXECUTABLES[@]}"; do | |
| chmod +x "$HOOKS_DIR/$f" | |
| done | |
| echo "==> Installed files into $HOOKS_DIR" | |
| # --- merge settings.json --- | |
| mkdir -p "$(dirname "$SETTINGS")" | |
| [[ -f "$SETTINGS" ]] || printf '{}\n' > "$SETTINGS" | |
| # Validate existing file first — refuse to touch a malformed settings.json. | |
| jq empty "$SETTINGS" 2>/dev/null || { | |
| echo "error: $SETTINGS is not valid JSON; fix it before rerunning" >&2 | |
| exit 1 | |
| } | |
| backup="${SETTINGS}.bak.$(date +%s)" | |
| cp "$SETTINGS" "$backup" | |
| tmp_settings=$(mktemp) | |
| jq \ | |
| --argjson new "$NEW_ENTRIES_JSON" \ | |
| --argjson managed "$MANAGED_CMDS_JSON" ' | |
| .hooks //= {} | | |
| .hooks.SessionStart //= [] | | |
| .hooks.SessionStart |= ( | |
| map( | |
| select( | |
| ([.hooks[]?.command] // []) as $cmds | |
| | ($managed | any(. as $m | $cmds | index($m))) | |
| | not | |
| ) | |
| ) + $new | |
| ) | |
| ' "$SETTINGS" > "$tmp_settings" | |
| jq empty "$tmp_settings" | |
| mv "$tmp_settings" "$SETTINGS" | |
| echo "==> Merged SessionStart hooks into $SETTINGS (backup: $backup)" | |
| cat <<'EOF' | |
| Done. Open /hooks in Claude Code (or restart) so the settings watcher | |
| picks up the new SessionStart entries. | |
| EOF |
| #!/usr/bin/env python3 | |
| # /// script | |
| # requires-python = ">=3.11" | |
| # dependencies = ["pyyaml"] | |
| # /// | |
| """Hook: Re-inject skills catalog after context compaction. | |
| Enumerates available SKILL.md files from known locations, extracts | |
| name + description from YAML frontmatter, and outputs a compact | |
| catalog as additionalContext. | |
| Intended for SessionStart with matcher:compact — the platform injects | |
| the catalog at initial startup but compaction compresses it away. | |
| """ | |
| import json | |
| import os | |
| import sys | |
| from pathlib import Path | |
| # PyYAML may not be installed; fall back to a minimal extractor. | |
| try: | |
| import yaml | |
| except ImportError: | |
| yaml = None | |
| def extract_frontmatter_raw(text: str) -> str | None: | |
| """Return the raw YAML frontmatter string between --- fences.""" | |
| if not text.startswith("---"): | |
| return None | |
| end = text.find("\n---", 3) | |
| if end == -1: | |
| return None | |
| return text[4:end] | |
| def parse_frontmatter(text: str) -> dict: | |
| """Parse YAML frontmatter from a SKILL.md file.""" | |
| raw = extract_frontmatter_raw(text) | |
| if raw is None: | |
| return {} | |
| if yaml is not None: | |
| try: | |
| data = yaml.safe_load(raw) | |
| return data if isinstance(data, dict) else {} | |
| except yaml.YAMLError: | |
| return {} | |
| # Minimal fallback: handle simple key: value pairs only. | |
| result = {} | |
| for line in raw.splitlines(): | |
| if ":" in line and not line.startswith(" "): | |
| key, _, val = line.partition(":") | |
| val = val.strip().strip("\"'") | |
| if val and val not in ("|-", ">-", "|", ">"): | |
| result[key.strip()] = val | |
| return result | |
| def _user_skill_dirs() -> list[Path]: | |
| """Return directories containing user-level SKILL.md files.""" | |
| home = Path.home() | |
| dirs = [home / ".claude" / "skills"] | |
| project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "") | |
| if project_dir: | |
| p = Path(project_dir) / ".claude" / "skills" | |
| if p.is_dir(): | |
| dirs.append(p) | |
| return dirs | |
| def _plugin_cache_dir() -> Path: | |
| """Return the plugin cache directory.""" | |
| return Path.home() / ".claude" / "plugins" / "cache" | |
| # Top-level dirs under plugins/cache to skip entirely. | |
| # Official plugin skills are managed via user symlinks; temp dirs are stale. | |
| _SKIP_PLUGIN_PREFIXES = ("claude-plugins-official", "temp_git_") | |
| def _read_skill(skill_file: Path) -> tuple[str, str] | None: | |
| """Read a SKILL.md and return (name, description) or None.""" | |
| try: | |
| text = skill_file.read_text(encoding="utf-8") | |
| except OSError: | |
| return None | |
| meta = parse_frontmatter(text) | |
| name = meta.get("name", "") | |
| if not name: | |
| return None | |
| desc = meta.get("description", "") | |
| desc = " ".join(desc.split()) | |
| if len(desc) > 250: | |
| desc = desc[:249] + "\u2026" | |
| return (name, desc) | |
| def _collect_user_skills() -> list[tuple[str, str]]: | |
| """Collect skills from user-level skill directories. | |
| Each child directory (or symlink-to-directory) is checked for a | |
| SKILL.md file. This avoids reliance on ``rglob`` following symlinks, | |
| which changed in Python 3.13. | |
| """ | |
| seen: set[str] = set() | |
| skills: list[tuple[str, str]] = [] | |
| for d in _user_skill_dirs(): | |
| if not d.is_dir(): | |
| continue | |
| for child in sorted(d.iterdir()): | |
| # Resolve symlinks so we can check is_dir on the target. | |
| if not child.is_dir() and not ( | |
| child.is_symlink() and child.resolve().is_dir() | |
| ): | |
| continue | |
| skill_file = child / "SKILL.md" | |
| if not skill_file.exists(): | |
| continue | |
| result = _read_skill(skill_file) | |
| if result is None: | |
| continue | |
| name, desc = result | |
| if name in seen: | |
| continue | |
| seen.add(name) | |
| skills.append((name, desc)) | |
| return skills | |
| def _collect_plugin_skills(exclude: set[str]) -> list[tuple[str, str]]: | |
| """Collect skills from the plugin cache, namespaced as ``plugin:skill``. | |
| Skips official/temp directories and any skill name already in *exclude*. | |
| """ | |
| cache = _plugin_cache_dir() | |
| if not cache.is_dir(): | |
| return [] | |
| seen: set[str] = set() | |
| skills: list[tuple[str, str]] = [] | |
| for plugin_dir in sorted(cache.iterdir()): | |
| if not plugin_dir.is_dir(): | |
| continue | |
| if any(plugin_dir.name.startswith(p) for p in _SKIP_PLUGIN_PREFIXES): | |
| continue | |
| namespace = plugin_dir.name | |
| # Walk the plugin tree to find all SKILL.md files. | |
| for root, _dirs, files in os.walk(plugin_dir, followlinks=True): | |
| if "SKILL.md" not in files: | |
| continue | |
| result = _read_skill(Path(root) / "SKILL.md") | |
| if result is None: | |
| continue | |
| raw_name, desc = result | |
| qualified = f"{namespace}:{raw_name}" | |
| if qualified in seen or raw_name in exclude: | |
| continue | |
| seen.add(qualified) | |
| skills.append((qualified, desc)) | |
| return skills | |
| def collect_skills() -> list[tuple[str, str]]: | |
| """Return deduplicated (name, description) pairs.""" | |
| user_skills = _collect_user_skills() | |
| user_names = {name for name, _ in user_skills} | |
| plugin_skills = _collect_plugin_skills(exclude=user_names) | |
| return user_skills + plugin_skills | |
| def main() -> None: | |
| skills = collect_skills() | |
| if not skills: | |
| sys.exit(0) | |
| lines = [] | |
| for name, desc in skills: | |
| if desc: | |
| lines.append(f"- {name}: {desc}") | |
| else: | |
| lines.append(f"- {name}") | |
| content = "## Available Skills\n\n" + "\n".join(lines) + "\n" | |
| output = { | |
| "hookSpecificOutput": { | |
| "hookEventName": "SessionStart", | |
| "additionalContext": content, | |
| } | |
| } | |
| json.dump(output, sys.stdout, indent=2) | |
| print() | |
| if __name__ == "__main__": | |
| main() |
Skills are on-demand specialized workflows available via the Skill tool.
They are curated to guide how you approach specific task types.
Before any response or action, scan the available skills list and invoke every plausibly relevant skill.
Skip skill invocation ONLY when:
Skill (e.g., skill: "commit-message").
The full content loads into context.python-testing to write these test cases.").User instructions define what to do. Skills define how. A user instruction is not permission to skip the skill check — always scan and invoke first, then follow user intent within the loaded workflow. Inferred urgency or simplicity is not an explicit user instruction; only skip skill steps when the user directly and specifically tells you to skip them.
When multiple skills apply, invoke in this order:
When ambiguous, treat the skill as rigid.
| #!/usr/bin/env bash | |
| # Hook: Inject skill-usage instructions at SessionStart. | |
| # | |
| # Reads using-skills-instr.md and outputs it as additionalContext | |
| # so Claude always has skill-usage guidance in context. | |
| set -euo pipefail | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| CONTENT_FILE="${SCRIPT_DIR}/using-skills-instr.md" | |
| if [[ ! -f "$CONTENT_FILE" ]]; then | |
| echo "using-skills: content file not found: ${CONTENT_FILE}" >&2 | |
| exit 0 # non-fatal — don't block the session | |
| fi | |
| content=$(<"$CONTENT_FILE") | |
| # Escape for safe JSON string embedding. | |
| to_json_string_literal() { | |
| local s="$1" | |
| if command -v jq >/dev/null 2>&1; then | |
| printf '%s' "${s}" | jq -Rs . | |
| return | |
| fi | |
| s="${s//\\/\\\\}" | |
| s="${s//\"/\\\"}" | |
| s="${s//$'\n'/\\n}" | |
| s="${s//$'\r'/\\r}" | |
| s="${s//$'\t'/\\t}" | |
| printf '"%s"' "${s}" | |
| } | |
| escaped=$(to_json_string_literal "${content}") | |
| # Both paths produce a complete JSON string literal (e.g. "..."). | |
| # Claude Code expects hookSpecificOutput.additionalContext (nested JSON). | |
| # Uses printf to avoid bash 5.3+ heredoc hang. | |
| printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": %s\n }\n}\n' "${escaped}" | |
| # Do note quote the %s in `"additionalContext": %s`! | |
| exit 0 |