Skip to content

Instantly share code, notes, and snippets.

@ahgraber
Last active April 16, 2026 20:50
Show Gist options
  • Select an option

  • Save ahgraber/f24792b25edeab8e029d621bbed54f49 to your computer and use it in GitHub Desktop.

Select an option

Save ahgraber/f24792b25edeab8e029d621bbed54f49 to your computer and use it in GitHub Desktop.
Claude-on-the-web setup script & skills hook

Claude on the startup script

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()

Using Skills

Skills are on-demand specialized workflows available via the Skill tool. They are curated to guide how you approach specific task types.

The Rule

Before any response or action, scan the available skills list and invoke every plausibly relevant skill.

  • If the skill's description shares any concept with the task at hand — the user's current request understood in the full context of the conversation — it is plausibly relevant.
  • Err toward invoking — the cost is near zero; skipping means missing established workflows. If unsure whether a skill applies, invoke it; do not require a perfect match.
  • Re-invoke skills when the task changes within a conversation. Prior invocations apply to prior tasks.
  • Skill invocation must precede all other cognitive work on the task. Read the request to understand what is being asked, then immediately scan and invoke — before analyzing the problem, forming an approach, or reasoning about implementation.
  • Do not explore the codebase, gather context, or ask clarifying questions before checking skills — skills often define how to do those things.

Limited Exceptions

Skip skill invocation ONLY when:

  1. You are a narrowly scoped subagent whose launch instructions already define your workflow.
  2. No skill is plausibly relevant after a good-faith scan. List the 2-3 closest candidate skills and why each was ruled out.
  3. The skill mechanism is unavailable or broken. State this explicitly.

Execution

  1. Read the user request.
  2. Scan the skills list (shown in system reminders) for matching names and trigger descriptions.
  3. Invoke every plausibly relevant skill via Skill (e.g., skill: "commit-message"). The full content loads into context.
  4. Follow the loaded instructions. If the skill includes a checklist, create TodoWrite items for each step.
  5. Announce which skill you are using and why (e.g., "Using python-testing to write these test cases.").

Instruction priority

  1. Highest priority: User instructions always take precedence, even over skill guidance.
  2. Skills guide default behavior via specialized knowledge.
  3. Public directives (CLAUDE.md, AGENTS.md) and system defaults are lowest priority.

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.

Ordering

When multiple skills apply, invoke in this order:

  1. Process skills (brainstorming, code-review, receiving-feedback) — shape your approach.
  2. Implementation skills (python-*, api-design, shell-scripts) — guide execution details.

Skill types

  • Rigid: the skill contains a numbered sequential workflow or checklist. Follow each step in order. Do not skip, reorder, or abbreviate.
  • Flexible: the skill provides principles, heuristics, or reference routing. Adapt the guidance to the specific context.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment